From a107beadcf1db9163692a41158c30780038dc7dd Mon Sep 17 00:00:00 2001
From: "Mr.yang" <1928658864@qq.com>
Date: Wed, 4 Oct 2023 21:55:43 +0800
Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E4=B8=80=E4=B8=AA=E5=AE=A2?=
=?UTF-8?q?=E6=88=B7=E7=AB=AFwcfautopy(=E5=9F=BA=E4=BA=8Epython=E5=AE=A2?=
=?UTF-8?q?=E6=88=B7=E7=AB=AF)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
clients/wcfautopy/MANIFEST.in | 2 +
clients/wcfautopy/README.MD | 163 ++++
clients/wcfautopy/demo.py | 78 ++
clients/wcfautopy/roomdata.proto | 21 +
clients/wcfautopy/setup.py | 45 ++
clients/wcfautopy/wcferry/__init__.py | 6 +
.../wcfautopy/wcferry/auto_res/__init__.py | 8 +
clients/wcfautopy/wcferry/auto_res/bot.py | 112 +++
clients/wcfautopy/wcferry/auto_res/core.py | 119 +++
clients/wcfautopy/wcferry/client.py | 694 ++++++++++++++++++
clients/wcfautopy/wcferry/event/__init__.py | 11 +
clients/wcfautopy/wcferry/event/core.py | 59 ++
clients/wcfautopy/wcferry/event/event.py | 33 +
clients/wcfautopy/wcferry/roomdata_pb2.py | 27 +
clients/wcfautopy/wcferry/wcf_pb2.py | 74 ++
clients/wcfautopy/wcferry/wxmsg.py | 123 ++++
16 files changed, 1575 insertions(+)
create mode 100644 clients/wcfautopy/MANIFEST.in
create mode 100644 clients/wcfautopy/README.MD
create mode 100644 clients/wcfautopy/demo.py
create mode 100644 clients/wcfautopy/roomdata.proto
create mode 100644 clients/wcfautopy/setup.py
create mode 100644 clients/wcfautopy/wcferry/__init__.py
create mode 100644 clients/wcfautopy/wcferry/auto_res/__init__.py
create mode 100644 clients/wcfautopy/wcferry/auto_res/bot.py
create mode 100644 clients/wcfautopy/wcferry/auto_res/core.py
create mode 100644 clients/wcfautopy/wcferry/client.py
create mode 100644 clients/wcfautopy/wcferry/event/__init__.py
create mode 100644 clients/wcfautopy/wcferry/event/core.py
create mode 100644 clients/wcfautopy/wcferry/event/event.py
create mode 100644 clients/wcfautopy/wcferry/roomdata_pb2.py
create mode 100644 clients/wcfautopy/wcferry/wcf_pb2.py
create mode 100644 clients/wcfautopy/wcferry/wxmsg.py
diff --git a/clients/wcfautopy/MANIFEST.in b/clients/wcfautopy/MANIFEST.in
new file mode 100644
index 0000000..e228aeb
--- /dev/null
+++ b/clients/wcfautopy/MANIFEST.in
@@ -0,0 +1,2 @@
+include wcferry/*.dll
+include wcferry/*.exe
diff --git a/clients/wcfautopy/README.MD b/clients/wcfautopy/README.MD
new file mode 100644
index 0000000..1b716a6
--- /dev/null
+++ b/clients/wcfautopy/README.MD
@@ -0,0 +1,163 @@
+# WeChatFerry wcfautopy 客户端(基于python客户端进行修改)
+[](https://pypi.python.org/pypi/wcferry) [](https://pypi.python.org/pypi/wcferry) [](https://wechatferry.readthedocs.io/zh/latest/?badge=latest)
+
+|[📖 文档](https://wechatferry.readthedocs.io/)|[📺 视频教程](https://mp.weixin.qq.com/s/APdjGyZ2hllXxyG_sNCfXQ)|[🙋 FAQ](https://mp.weixin.qq.com/s/vAGpn1C9stI8Xzt1hUJhLA)|
+|:-:|:-:|:-:|
+
+🤖示例机器人框架:[WeChatRobot](https://github.com/lich0821/WeChatRobot)。
+
+## 快速开始
+```sh
+pip install --upgrade wcferry
+```
+
+### Demo:
+```py
+#! /usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import logging
+from time import sleep
+from wcferry import Wcf, WxMsg, Register
+
+logging.basicConfig(level='DEBUG', format="%(asctime)s %(message)s")
+LOG = logging.getLogger("Demo")
+
+
+
+def main():
+ receiver = Register()
+
+ @receiver.message_register(isDivision=True, isGroup=True, isPyq=False)
+ def process_msg(bot: Wcf, msg: WxMsg):
+ """
+ 同步消息函数装饰器
+ """
+ LOG.info(f"收到消息: {msg}")
+
+ sleep(5) # 等微信加载好,以免信息显示异常
+ LOG.info(f"已经登录: {True if bot.is_login() else False}")
+ LOG.info(f"wxid: {bot.get_self_wxid()}")
+
+ # bot.disable_recv_msg() # 当需要停止接收消息时调用
+ sleep(5)
+ ret = bot.send_text("Hello world.", "filehelper")
+ LOG.info(f"send_text: {ret}")
+
+ sleep(5)
+ # 需要确保图片路径正确,建议使用绝对路径(使用双斜杠\\)
+ ret = bot.send_image("https://raw.githubusercontent.com/lich0821/WeChatFerry/master/assets/QR.jpeg", "filehelper")
+ LOG.info(f"send_image: {ret}")
+
+ sleep(5)
+ # 需要确保文件路径正确,建议使用绝对路径(使用双斜杠\\)
+ ret = bot.send_file("https://raw.githubusercontent.com/lich0821/WeChatFerry/master/README.MD", "filehelper")
+ LOG.info(f"send_file: {ret}")
+
+ sleep(5)
+ LOG.info(f"Message types:\n{bot.get_msg_types()}")
+ LOG.info(f"Contacts:\n{bot.get_contacts()}")
+
+ sleep(5)
+ LOG.info(f"DBs:\n{bot.get_dbs()}")
+ LOG.info(f"Tables:\n{bot.get_tables('db')}")
+ LOG.info(f"Results:\n{bot.query_sql('MicroMsg.db', 'SELECT * FROM Contact LIMIT 1;')}")
+
+ # 需要真正的 V3、V4 信息
+ # bot.accept_new_friend("v3", "v4")
+
+ # 添加群成员,填写正确的群 ID 和成员 wxid
+ # ret = bot.add_chatroom_members("chatroom id", "wxid1,wxid2,wxid3,...")
+ # LOG.info(f"add_chatroom_members: {ret}")
+
+ # 删除群成员,填写正确的群 ID 和成员 wxid
+ # ret = bot.del_chatroom_members("chatroom id", "wxid1,wxid2,wxid3,...")
+ # LOG.info(f"add_chatroom_members: {ret}")
+
+ sleep(5)
+ bot.refresh_pyq(0) # 刷新朋友圈第一页
+ # bot.refresh_pyq(id) # 从 id 开始刷新朋友圈
+
+ @receiver.async_message_register()
+ async def async_process_msg(bot: Wcf, msg: WxMsg):
+ """
+ 异步消息函数装饰器
+ """
+ print(msg)
+
+ # 开始接受消息
+ receiver.run()
+
+
+if __name__ == "__main__":
+ main()
+
+
+```
+
+|||
+|:-:|:-:|
+|后台回复 `WeChatFerry` 加群交流|如果你觉得有用|
+
+## 一起开发
+### 配置环境
+```sh
+# 创建虚拟环境
+python -m venv .env
+# 激活虚拟环境
+source .env/Scripts/activate
+# 升级 pip
+pip install --upgrade pip
+# 安装依赖包
+pip install grpcio-tools pynng
+```
+
+### 重新生成 PB 文件
+```sh
+# CMD
+cd clients\wcfautopy\wcferry
+python -m grpc_tools.protoc --python_out=. --proto_path=..\..\..\WeChatFerry\rpc\proto\ wcf.proto
+
+# GitBash
+cd clients/wcfautopy/wcferry
+python -m grpc_tools.protoc --python_out=. --proto_path=../../../WeChatFerry/rpc/proto/ wcf.proto
+```
+
+## 版本更新
+### 39.0.3.0 (2023.09.28)
+* 修复登录账号昵称超长报错问题
+
+点击查看更多
+
+版本号:`w.x.y.z`。
+
+其中:
+* `w` 是微信的大版本号,如 `37` (3.7.a.a), `38` (3.8.a.a), `39` (3.9.a.a)
+* `x` 是适配的微信的小版本号,从 0 开始
+* `y` 是 `WeChatFerry` 的版本,从 0 开始
+* `z` 是各客户端的版本,从 0 开始
+
+功能:
+
+* 检查登录状态
+* 获取登录账号的 wxid
+* 获取消息类型
+* 获取所有联系人
+* 获取所有好友
+* 获取数据库
+* 获取某数据库下的表
+* 获取用户信息
+* 发送文本消息(可 @)
+* 发送图片(wcfautopy 客户端支持网络路径)
+* 发送文件(wcfautopy 客户端支持网络路径)
+* 允许接收消息
+* 停止接收消息
+* 执行 SQL 查询
+* 接受好友申请
+* 添加群成员
+* 删除群成员
+* 解密图片
+* 获取朋友圈消息
+* 某功能(Breaking Change)
+
+
diff --git a/clients/wcfautopy/demo.py b/clients/wcfautopy/demo.py
new file mode 100644
index 0000000..0ace02c
--- /dev/null
+++ b/clients/wcfautopy/demo.py
@@ -0,0 +1,78 @@
+#! /usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+import logging
+from time import sleep
+from wcferry import Wcf, WxMsg, Register
+
+logging.basicConfig(level='DEBUG', format="%(asctime)s %(message)s")
+LOG = logging.getLogger("Demo")
+
+
+
+def main():
+ receiver = Register()
+
+ @receiver.message_register(isDivision=True, isGroup=True, isPyq=False)
+ def process_msg(bot: Wcf, msg: WxMsg):
+ """
+ 同步消息函数装饰器
+ """
+ LOG.info(f"收到消息: {msg}")
+
+ sleep(5) # 等微信加载好,以免信息显示异常
+ LOG.info(f"已经登录: {True if bot.is_login() else False}")
+ LOG.info(f"wxid: {bot.get_self_wxid()}")
+
+ # bot.disable_recv_msg() # 当需要停止接收消息时调用
+ sleep(5)
+ ret = bot.send_text("Hello world.", "filehelper")
+ LOG.info(f"send_text: {ret}")
+
+ sleep(5)
+ # 需要确保图片路径正确,建议使用绝对路径(使用双斜杠\\)
+ ret = bot.send_image("https://raw.githubusercontent.com/lich0821/WeChatFerry/master/assets/QR.jpeg", "filehelper")
+ LOG.info(f"send_image: {ret}")
+
+ sleep(5)
+ # 需要确保文件路径正确,建议使用绝对路径(使用双斜杠\\)
+ ret = bot.send_file("https://raw.githubusercontent.com/lich0821/WeChatFerry/master/README.MD", "filehelper")
+ LOG.info(f"send_file: {ret}")
+
+ sleep(5)
+ LOG.info(f"Message types:\n{bot.get_msg_types()}")
+ LOG.info(f"Contacts:\n{bot.get_contacts()}")
+
+ sleep(5)
+ LOG.info(f"DBs:\n{bot.get_dbs()}")
+ LOG.info(f"Tables:\n{bot.get_tables('db')}")
+ LOG.info(f"Results:\n{bot.query_sql('MicroMsg.db', 'SELECT * FROM Contact LIMIT 1;')}")
+
+ # 需要真正的 V3、V4 信息
+ # bot.accept_new_friend("v3", "v4")
+
+ # 添加群成员,填写正确的群 ID 和成员 wxid
+ # ret = bot.add_chatroom_members("chatroom id", "wxid1,wxid2,wxid3,...")
+ # LOG.info(f"add_chatroom_members: {ret}")
+
+ # 删除群成员,填写正确的群 ID 和成员 wxid
+ # ret = bot.del_chatroom_members("chatroom id", "wxid1,wxid2,wxid3,...")
+ # LOG.info(f"add_chatroom_members: {ret}")
+
+ sleep(5)
+ bot.refresh_pyq(0) # 刷新朋友圈第一页
+ # bot.refresh_pyq(id) # 从 id 开始刷新朋友圈
+
+ @receiver.async_message_register()
+ async def async_process_msg(bot: Wcf, msg: WxMsg):
+ """
+ 异步消息函数装饰器
+ """
+ print(msg)
+
+ # 开始接受消息
+ receiver.run()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/clients/wcfautopy/roomdata.proto b/clients/wcfautopy/roomdata.proto
new file mode 100644
index 0000000..22f307e
--- /dev/null
+++ b/clients/wcfautopy/roomdata.proto
@@ -0,0 +1,21 @@
+syntax = "proto3";
+package com.iamteer.wcf;
+
+message RoomData {
+
+ message RoomMember {
+ string wxid = 1;
+ string name = 2;
+ int32 state = 3;
+ }
+
+ repeated RoomMember members = 1;
+
+ int32 field_2 = 2;
+ int32 field_3 = 3;
+ int32 field_4 = 4;
+ int32 room_capacity = 5;
+ int32 field_6 = 6;
+ int64 field_7 = 7;
+ int64 field_8 = 8;
+}
diff --git a/clients/wcfautopy/setup.py b/clients/wcfautopy/setup.py
new file mode 100644
index 0000000..2f1fa75
--- /dev/null
+++ b/clients/wcfautopy/setup.py
@@ -0,0 +1,45 @@
+#! /usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+
+from __future__ import print_function
+from setuptools import setup, find_packages
+
+import wcferry
+
+with open("README.md", "r", encoding="utf-8") as fh:
+ long_description = fh.read()
+
+
+setup(
+ name="wcferry",
+ version=wcferry.__version__,
+ author="Changhua",
+ author_email="lichanghua0821@gmail.com",
+ description="一个玩微信的工具",
+ long_description=long_description,
+ long_description_content_type="text/markdown",
+ license="MIT",
+ url="https://github.com/lich0821/WeChatFerry",
+ python_requires=">=3.8",
+ packages=find_packages(),
+ include_package_data=True,
+ install_requires=[
+ "setuptools",
+ "grpcio-tools",
+ "pynng",
+ "requests",
+ ],
+ classifiers=[
+ "Environment :: Win32 (MS Windows)",
+ "Intended Audience :: Developers",
+ "Intended Audience :: Customer Service",
+ "Topic :: Communications :: Chat",
+ "Operating System :: Microsoft :: Windows",
+ "Programming Language :: Python",
+ ],
+ project_urls={
+ "Documentation": "https://wechatferry.readthedocs.io/zh/latest/index.html",
+ "GitHub": "https://github.com/lich0821/WeChatFerry/",
+ },
+)
diff --git a/clients/wcfautopy/wcferry/__init__.py b/clients/wcfautopy/wcferry/__init__.py
new file mode 100644
index 0000000..f5d4343
--- /dev/null
+++ b/clients/wcfautopy/wcferry/__init__.py
@@ -0,0 +1,6 @@
+# -*- coding: utf-8 -*-
+
+from wcferry.client import Wcf, __version__
+from wcferry.wxmsg import WxMsg
+from wcferry.auto_res import Register
+
diff --git a/clients/wcfautopy/wcferry/auto_res/__init__.py b/clients/wcfautopy/wcferry/auto_res/__init__.py
new file mode 100644
index 0000000..320befe
--- /dev/null
+++ b/clients/wcfautopy/wcferry/auto_res/__init__.py
@@ -0,0 +1,8 @@
+# -*- coding: utf-8 -*-
+
+from wcferry.auto_res.bot import Register
+from wcferry.auto_res.core import load_function
+
+
+Register = load_function(Register)
+
diff --git a/clients/wcfautopy/wcferry/auto_res/bot.py b/clients/wcfautopy/wcferry/auto_res/bot.py
new file mode 100644
index 0000000..e30aae7
--- /dev/null
+++ b/clients/wcfautopy/wcferry/auto_res/bot.py
@@ -0,0 +1,112 @@
+# -*- coding: utf-8 -*-
+
+from typing import Callable, Any
+from wcferry.event import Event
+from abc import abstractmethod
+from wcferry.client import Wcf
+import logging
+
+
+class Register(Event):
+ def __init__(self, debug=True, **kwargs):
+ super(Register, self).__init__()
+ logging.basicConfig(level='DEBUG', format="%(asctime)s %(message)s")
+ self._LOG = logging.getLogger("Demo")
+ self._LOG.info("开始初始化...")
+ # 默认连接本地服务
+ self._wcf = Wcf(debug=debug, **kwargs)
+
+ @abstractmethod
+ def _process_msg(self, wcf: Wcf):
+ """
+ 有消息的时候,通知分发器分发消息
+ :param wcf: Wcf
+ :return: None
+ """
+ raise NotImplementedError
+
+ @abstractmethod
+ def _register(self,
+ func: Callable[[Any], Any]):
+ """
+ 消息处理工厂, 所有消息处理函数都会汇总在这里提交给事件分发器
+ :param func: 被装饰的待处理消息函数
+ :return: func
+ """
+ raise NotImplementedError
+
+ @abstractmethod
+ def _processing_async_func(self,
+ isGroup: bool,
+ isDivision: bool,
+ isPyq: bool):
+ """
+ 异步函数消息处理函数, 用来接受非协程函数
+ 参数:
+ :param isGroup 对消息进行限制, 当为True时, 只接受群消息, 当为False时, 只接受私聊消息,
+ 注意! 仅当isDivision为 True时, isGroup参数生效
+ :param isPyq 是否接受朋友圈消息
+ :param isDivision 是否对消息分组
+ """
+ raise NotImplementedError
+
+ @abstractmethod
+ def _processing_universal_func(self,
+ isGroup: bool,
+ isDivision: bool,
+ isPyq: bool):
+ """
+ 同步函数消息处理函数, 用来接受非协程函数
+ 参数:
+ :param isGroup 对消息进行限制, 当为True时, 只接受群消息, 当为False时, 只接受私聊消息,
+ 注意! 仅当isDivision为 True时, isGroup参数生效
+ :param isPyq 是否接受朋友圈消息
+ :param isDivision 是否对消息分组
+ """
+ raise NotImplementedError
+
+ @abstractmethod
+ def message_register(self,
+ isGroup: bool = False,
+ isDivision: bool = False,
+ isPyq: bool = False):
+ """
+ 外部可访问接口
+ 消息处理函数注册器, 用来接受同步函数
+ 参数:
+ :param isGroup 对消息进行限制, 当为True时, 只接受群消息, 当为False时, 只接受私聊消息,
+ 注意! 仅当isDivision为 True时, isGroup参数生效
+ :param isPyq 是否接受朋友圈消息
+ :param isDivision 是否对消息分组
+ """
+ raise NotImplementedError
+
+ @abstractmethod
+ def async_message_register(self,
+ isGroup: bool = False,
+ isDivision: bool = False,
+ isPyq: bool = False):
+ """
+ 外部可访问接口
+ 消息处理函数注册器, 用来接受异步函数
+ 参数:
+ :param isGroup 对消息进行限制, 当为True时, 只接受群消息, 当为False时, 只接受私聊消息,
+ 注意! 仅当isDivision为 True时, isGroup参数生效
+ :param isPyq 是否接受朋友圈消息
+ :param isDivision 是否对消息分组
+ """
+ raise NotImplementedError
+
+ @abstractmethod
+ def run(self, *args, **kwargs):
+ """
+ 启动程序, 开始接受消息
+ """
+ raise NotImplementedError
+
+ @abstractmethod
+ def stop_receiving(self):
+ """
+ 停止接受消息
+ """
+ raise NotImplementedError
diff --git a/clients/wcfautopy/wcferry/auto_res/core.py b/clients/wcfautopy/wcferry/auto_res/core.py
new file mode 100644
index 0000000..e883964
--- /dev/null
+++ b/clients/wcfautopy/wcferry/auto_res/core.py
@@ -0,0 +1,119 @@
+# -*- coding: utf-8 -*-
+
+import asyncio
+import functools
+import queue
+import traceback
+from threading import Thread
+from typing import Callable, Any
+from wcferry.client import Wcf
+from wcferry.wxmsg import WxMsg
+
+
+def load_function(cls):
+ cls._process_msg = _process_msg
+ cls._register = _register
+ cls._processing_async_func = _processing_async_func
+ cls._processing_universal_func = _processing_universal_func
+ cls.message_register = message_register
+ cls.async_message_register = async_message_register
+ cls.run = run
+ cls.stop_receiving = stop_receiving
+ return cls
+
+
+def _process_msg(self, wcf: Wcf):
+ """有消息的时候,通知分发器分发消息"""
+ while wcf.is_receiving_msg():
+ try:
+ msg = wcf.get_msg()
+ self._message = msg
+ self._run_func()
+ except queue.Empty:
+ pass
+
+
+def _register(self,
+ func: Callable[[Any], Any]):
+ self._add_callback(func, self._wcf)
+ # 此处必须返回被装饰函数原函数, 否则丢失被装饰函数信息
+ return func
+
+
+def _processing_async_func(self,
+ isGroup: bool,
+ isDivision: bool,
+ isPyq: bool,):
+ def _async_func(func):
+
+ @functools.wraps(func)
+ @self._register
+ async def __async_func(bot: Wcf, message: WxMsg):
+ try:
+ # 判断被装饰函数是否为协程函数, 本函数要求是协程函数
+ if not asyncio.iscoroutinefunction(func): raise ValueError(
+ f'这里应使用协程函数, 而被装饰函数-> ({func.__name__}) <-是非协程函数')
+ if message.is_pyq() and isPyq:
+ return await func(bot, message)
+ if not isDivision:
+ return await func(bot, message)
+ if message.from_group() and isGroup:
+ return await func(bot, message)
+ if not message.from_group() and not isGroup:
+ return await func(bot, message)
+ except:
+ traceback.print_exc()
+ return __async_func
+ return _async_func
+
+
+def _processing_universal_func(self,
+ isGroup: bool,
+ isDivision: bool,
+ isPyq: bool, ):
+ def _universal_func(func):
+
+ @functools.wraps(func)
+ @self._register
+ def universal_func(bot: Wcf, message: WxMsg):
+ try:
+ # 判断被装饰函数是否为协程函数, 本函数要求是协程函数
+ if asyncio.iscoroutinefunction(func): raise ValueError(
+ f'这里应使用非协程函数, 而被装饰函数-> ({func.__name__}) <-协程函数')
+ if message.is_pyq() and isPyq:
+ return func(bot, message)
+ if not isDivision:
+ return func(bot, message)
+ if message.from_group() and isGroup:
+ return func(bot, message)
+ if not message.from_group() and not isGroup:
+ return func(bot, message)
+ except:
+ traceback.print_exc()
+ return None
+ return universal_func
+ return _universal_func
+
+
+def message_register(self,
+ isGroup: bool = False,
+ isDivision: bool = False,
+ isPyq: bool = False):
+ return self._processing_universal_func(isGroup, isDivision, isPyq)
+
+
+def async_message_register(self,
+ isGroup: bool = False,
+ isDivision: bool = False,
+ isPyq: bool = False):
+ return self._processing_async_func(isGroup, isDivision, isPyq)
+
+
+def run(self, *args, **kwargs):
+ self._wcf.enable_receiving_msg(*args, pyq=True, **kwargs)
+ Thread(target=self._process_msg, name="GetMessage", args=(self._wcf,), daemon=True).start()
+ self._LOG.debug("开始接受消息")
+ self._wcf.keep_running()
+
+def stop_receiving(self):
+ return self._wcf.disable_recv_msg()
diff --git a/clients/wcfautopy/wcferry/client.py b/clients/wcfautopy/wcferry/client.py
new file mode 100644
index 0000000..9136f47
--- /dev/null
+++ b/clients/wcfautopy/wcferry/client.py
@@ -0,0 +1,694 @@
+#! /usr/bin/env python3
+# -*- coding: utf-8 -*-
+
+__version__ = "39.0.3.0"
+
+import atexit
+import base64
+import logging
+import mimetypes
+import os
+import re
+import sys
+from queue import Queue
+from threading import Thread
+from time import sleep
+from typing import Callable, Dict, List, Optional
+
+import pynng
+import requests
+from google.protobuf import json_format
+from wcferry import wcf_pb2
+from wcferry.roomdata_pb2 import RoomData
+from wcferry.wxmsg import WxMsg
+
+
+def _retry():
+ def decorator(func):
+ """ Retry the function """
+ def wrapper(*args, **kwargs):
+ def logerror(e):
+ func_name = re.findall(r"func: (.*?)\n", str(args[1]))[-1]
+ logging.getLogger("WCF").error(f"Call {func_name} failed: {e}")
+
+ try:
+ ret = func(*args, **kwargs)
+ except pynng.Timeout as _: # 如果超时,重试
+ try:
+ ret = func(*args, **kwargs)
+ except Exception as e:
+ logerror(e)
+ ret = wcf_pb2.Response()
+ except Exception as e: # 其他异常,退出
+ logerror(e)
+ sys.exit(-1)
+
+ return ret
+ return wrapper
+ return decorator
+
+
+class Wcf():
+ """WeChatFerry, 一个玩微信的工具。
+
+ Args:
+ host (str): `wcferry` RPC 服务器地址,默认本地启动;也可以指定地址连接远程服务
+ port (int): `wcferry` RPC 服务器端口,默认为 10086,接收消息会占用 `port+1` 端口
+ debug (bool): 是否开启调试模式(仅本地启动有效)
+
+ Attributes:
+ contacts (list): 联系人缓存,调用 `get_contacts` 后更新
+ self_wxid (str): 登录账号 wxid
+ """
+
+ def __init__(self, host: str = None, port: int = 10086, debug: bool = True) -> None:
+ self._local_mode = False
+ self._is_running = False
+ self._is_receiving_msg = False
+ self._wcf_root = os.path.abspath(os.path.dirname(__file__))
+ self._dl_path = f"{self._wcf_root}/.dl"
+ os.makedirs(self._dl_path, exist_ok=True)
+ self.LOG = logging.getLogger("WCF")
+ self.LOG.info(f"wcferry version: {__version__}")
+ self.port = port
+ self.host = host
+ if host is None:
+ self._local_mode = True
+ self.host = "127.0.0.1"
+ cmd = fr'"{self._wcf_root}\wcf.exe" start {self.port} {"debug" if debug else ""}'
+ if os.system(cmd) != 0:
+ self.LOG.error("初始化失败!")
+ os._exit(-1)
+
+ self.cmd_url = f"tcp://{self.host}:{self.port}"
+
+ # 连接 RPC
+ self.cmd_socket = pynng.Pair1() # Client --> Server,发送消息
+ self.cmd_socket.send_timeout = 2000 # 发送 2 秒超时
+ self.cmd_socket.recv_timeout = 2000 # 接收 2 秒超时
+ try:
+ self.cmd_socket.dial(self.cmd_url, block=True)
+ except Exception as e:
+ self.LOG.error(f"连接失败: {e}")
+ os._exit(-2)
+
+ self.msg_socket = pynng.Pair1() # Server --> Client,接收消息
+ self.msg_socket.send_timeout = 2000 # 发送 2 秒超时
+ self.msg_socket.recv_timeout = 2000 # 接收 2 秒超时
+ self.msg_url = self.cmd_url.replace(str(self.port), str(self.port + 1))
+
+ atexit.register(self.cleanup) # 退出的时候停止消息接收,防止资源占用
+ while not self.is_login(): # 等待微信登录成功
+ sleep(1)
+
+ self._is_running = True
+ self.contacts = []
+ self.msgQ = Queue()
+ self._SQL_TYPES = {1: int, 2: float, 3: lambda x: x.decode("utf-8"), 4: bytes, 5: lambda x: None}
+ self.self_wxid = self.get_self_wxid()
+
+ def __del__(self) -> None:
+ self.cleanup()
+
+ def cleanup(self) -> None:
+ """关闭连接,回收资源"""
+ if not self._is_running:
+ return
+
+ self.disable_recv_msg()
+ self.cmd_socket.close()
+
+ if self._local_mode:
+ cmd = fr'"{self._wcf_root}\wcf.exe" stop'
+ if os.system(cmd) != 0:
+ self.LOG.error("退出失败!")
+ return
+ self._is_running = False
+
+ def keep_running(self):
+ """阻塞进程,让 RPC 一直维持连接"""
+ try:
+ while True:
+ sleep(1)
+ except Exception as e:
+ self.cleanup()
+
+ @_retry()
+ 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_receiving_msg(self) -> bool:
+ """是否已启动接收消息功能"""
+ return self._is_receiving_msg
+
+ def is_login(self) -> bool:
+ """是否已经登录"""
+ req = wcf_pb2.Request()
+ 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:
+ """获取登录账户的 wxid"""
+ req = wcf_pb2.Request()
+ req.func = wcf_pb2.FUNC_GET_SELF_WXID # FUNC_GET_SELF_WXID
+ rsp = self._send_request(req)
+
+ return rsp.str
+
+ def get_msg_types(self) -> Dict:
+ """获取所有消息类型"""
+ req = wcf_pb2.Request()
+ req.func = wcf_pb2.FUNC_GET_MSG_TYPES # FUNC_GET_MSG_TYPES
+ rsp = self._send_request(req)
+ types = json_format.MessageToDict(rsp.types).get("types", {})
+ types = {int(k): v for k, v in types.items()}
+
+ 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).get("contacts", [])
+
+ self.contacts.clear()
+ for cnt in contacts:
+ gender = cnt.get("gender", "")
+ if gender == 1:
+ gender = "男"
+ elif gender == 2:
+ gender = "女"
+ else:
+ gender = ""
+ contact = {
+ "wxid": cnt.get("wxid", ""),
+ "code": cnt.get("code", ""),
+ "remark": cnt.get("remark", ""),
+ "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).get("names", [])
+
+ return dbs
+
+ def get_tables(self, db: str) -> List[Dict]:
+ """获取 db 中所有表
+
+ Args:
+ db (str): 数据库名(可通过 `get_dbs` 查询)
+
+ Returns:
+ 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 get_user_info(self) -> Dict:
+ """获取登录账号个人信息"""
+ req = wcf_pb2.Request()
+ req.func = wcf_pb2.FUNC_GET_USER_INFO # FUNC_GET_USER_INFO
+ rsp = self._send_request(req)
+ ui = json_format.MessageToDict(rsp.ui)
+
+ return ui
+
+ def send_text(self, msg: str, receiver: str, aters: Optional[str] = "") -> int:
+ """发送文本消息
+
+ Args:
+ msg (str): 要发送的消息,换行使用 `\\\\n` (单杠);如果 @ 人的话,需要带上跟 `aters` 里数量相同的 @
+ receiver (str): 消息接收人,wxid 或者 roomid
+ aters (str): 要 @ 的 wxid,多个用逗号分隔;`@所有人` 只需要 `notify@all`
+
+ Returns:
+ int: 0 为成功,其他失败
+ """
+ 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 _download_file(self, url: str) -> str:
+ path = None
+ if not self._local_mode:
+ self.LOG.error(f"只有本地模式才支持网络路径!")
+ return path
+
+ try:
+ headers = {
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.127 Safari/537.36', }
+ rsp = requests.get(url, headers=headers, stream=True, timeout=60)
+ rsp.raw.decode_content = True
+
+ # 提取文件名
+ fname = os.path.basename(url)
+ ct = rsp.headers["content-type"]
+ ext = mimetypes.guess_extension(ct)
+ if ext:
+ if ext not in fname:
+ fname = fname + ext
+ else:
+ fname = fname.split(ext)[0] + ext
+
+ # 保存文件,用完后删除
+ with open(f"{self._dl_path}/{fname}", "wb") as of:
+ of.write(rsp.content)
+
+ path = os.path.normpath(f"{self._dl_path}/{fname}")
+ except Exception as e:
+ self.LOG.error(f"网络资源下载失败: {e}")
+
+ return path
+
+ def _process_path(self, path) -> str:
+ """处理路径,如果是网络路径则下载文件
+ """
+ if path.startswith("http"):
+ path = self._download_file(path)
+ if not path:
+ return -102 # 下载失败
+ elif not os.path.exists(path):
+ self.LOG.error(f"图片或者文件不存在,请检查路径: {path}")
+ return -101 # 文件不存在
+
+ return path
+
+ def send_image(self, path: str, receiver: str) -> int:
+ """发送图片,非线程安全
+
+ Args:
+ path (str): 图片路径,如:`C:/Projs/WeChatRobot/TEQuant.jpeg` 或 `https://raw.githubusercontent.com/lich0821/WeChatFerry/master/assets/TEQuant.jpg`
+ receiver (str): 消息接收人,wxid 或者 roomid
+
+ Returns:
+ int: 0 为成功,其他失败
+ """
+ path = self._process_path(path)
+ if isinstance(path, int):
+ return path
+
+ 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:
+ """发送文件,非线程安全
+
+ Args:
+ path (str): 本地文件路径,如:`C:/Projs/WeChatRobot/README.MD` 或 `https://raw.githubusercontent.com/lich0821/WeChatFerry/master/README.MD`
+ receiver (str): 消息接收人,wxid 或者 roomid
+
+ Returns:
+ int: 0 为成功,其他失败
+ """
+ path = self._process_path(path)
+ if isinstance(path, int):
+ return path
+
+ 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 send_xml(self, receiver: str, xml: str, type: int, path: str = None) -> int:
+ """发送 XML
+
+ Args:
+ receiver (str): 消息接收人,wxid 或者 roomid
+ xml (str): xml 内容
+ type (int): xml 类型,如:0x21 为小程序
+ path (str): 封面图片路径
+
+ Returns:
+ int: 0 为成功,其他失败
+ """
+ raise Exception("Not implemented, yet")
+ req = wcf_pb2.Request()
+ req.func = wcf_pb2.FUNC_SEND_XML # FUNC_SEND_XML
+ req.xml.receiver = receiver
+ req.xml.content = xml
+ req.xml.type = type
+ if path:
+ req.xml.path = path
+ rsp = self._send_request(req)
+ return rsp.status
+
+ def send_emotion(self, path: str, receiver: str) -> int:
+ """发送表情
+
+ Args:
+ path (str): 本地表情路径,如:`C:/Projs/WeChatRobot/emo.gif`
+ receiver (str): 消息接收人,wxid 或者 roomid
+
+ Returns:
+ int: 0 为成功,其他失败
+ """
+ raise Exception("Not implemented, yet")
+ req = wcf_pb2.Request()
+ req.func = wcf_pb2.FUNC_SEND_EMOTION # FUNC_SEND_EMOTION
+ req.file.path = path
+ req.file.receiver = receiver
+ rsp = self._send_request(req)
+ return rsp.status
+
+ def get_msg(self, block=True) -> WxMsg:
+ """从消息队列中获取消息
+
+ Args:
+ block (bool): 是否阻塞,默认阻塞
+
+ Returns:
+ WxMsg: 微信消息
+
+ Raises:
+ Empty: 如果阻塞并且超时,抛出空异常,需要用户自行捕获
+ """
+ return self.msgQ.get(block, timeout=1)
+
+ def enable_receiving_msg(self, pyq=False) -> bool:
+ """允许接收消息,成功后通过 `get_msg` 读取消息"""
+ 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:
+ self.msgQ.put(WxMsg(rsp.wxmsg))
+
+ # 退出前关闭通信通道
+ self.msg_socket.close()
+
+ if self._is_receiving_msg:
+ return True
+
+ req = wcf_pb2.Request()
+ req.func = wcf_pb2.FUNC_ENABLE_RECV_TXT # FUNC_ENABLE_RECV_TXT
+ req.flag = pyq
+ rsp = self._send_request(req)
+ if rsp.status != 0:
+ return False
+
+ self._is_receiving_msg = True
+ # 阻塞,把控制权交给用户
+ # self.listening_msg(callback)
+
+ # 不阻塞,启动一个新的线程来接收消息
+ Thread(target=listening_msg, name="GetMessage", daemon=True).start()
+
+ return True
+
+ def enable_recv_msg(self, callback: Callable[[WxMsg], None] = None) -> bool:
+ """(不建议使用)设置接收消息回调,消息量大时可能会丢失消息
+
+ .. deprecated:: 3.7.0.30.13
+ """
+ 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(WxMsg(rsp.wxmsg))
+ # 退出前关闭通信通道
+ self.msg_socket.close()
+
+ if self._is_receiving_msg:
+ return True
+
+ if callback is None:
+ return False
+
+ 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
+ # 阻塞,把控制权交给用户
+ # listening_msg()
+
+ # 不阻塞,启动一个新的线程来接收消息
+ Thread(target=listening_msg, name="GetMessage", daemon=True).start()
+
+ return True
+
+ def disable_recv_msg(self) -> int:
+ """停止接收消息"""
+ if not self._is_receiving_msg:
+ return 0
+
+ req = wcf_pb2.Request()
+ req.func = wcf_pb2.FUNC_DISABLE_RECV_TXT # FUNC_DISABLE_RECV_TXT
+ rsp = self._send_request(req)
+ self._is_receiving_msg = False
+
+ return rsp.status
+
+ def query_sql(self, db: str, sql: str) -> List[Dict]:
+ """执行 SQL,如果数据量大注意分页,以免 OOM
+
+ Args:
+ db (str): 要查询的数据库
+ sql (str): 要执行的 SQL
+
+ Returns:
+ List[Dict]: 查询结果
+ """
+ result = []
+ req = wcf_pb2.Request()
+ 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).get("rows", [])
+ for r in rows:
+ row = {}
+ for f in r["fields"]:
+ c = base64.b64decode(f.get("content", ""))
+ row[f["column"]] = self._SQL_TYPES[f["type"]](c)
+ result.append(row)
+ return result
+
+ def accept_new_friend(self, v3: str, v4: str, scene: int = 30) -> int:
+ """通过好友申请
+
+ Args:
+ v3 (str): 加密用户名 (好友申请消息里 v3 开头的字符串)
+ v4 (str): Ticket (好友申请消息里 v4 开头的字符串)
+ scene: 申请方式 (好友申请消息里的 scene); 为了兼容旧接口,默认为扫码添加 (30)
+
+ Returns:
+ int: 1 为成功,其他失败
+ """
+ req = wcf_pb2.Request()
+ req.func = wcf_pb2.FUNC_ACCEPT_FRIEND # FUNC_ACCEPT_FRIEND
+ req.v.v3 = v3
+ req.v.v4 = v4
+ req.v.scene = scene
+ rsp = self._send_request(req)
+ return rsp.status
+
+ def get_friends(self) -> List[Dict]:
+ """获取好友列表"""
+ not_friends = {
+ "fmessage": "朋友推荐消息",
+ "medianote": "语音记事本",
+ "floatbottle": "漂流瓶",
+ "filehelper": "文件传输助手",
+ "newsapp": "新闻",
+ }
+ friends = []
+ for cnt in self.get_contacts():
+ if (cnt["wxid"].endswith("@chatroom") or # 群聊
+ cnt["wxid"].startswith("gh_") or # 公众号
+ cnt["wxid"] in not_friends.keys() # 其他杂号
+ ):
+ continue
+ friends.append(cnt)
+
+ return friends
+
+ def receive_transfer(self, wxid: str, transferid: str, transactionid: str) -> int:
+ """接收转账
+
+ Args:
+ wxid (str): 转账消息里的发送人 wxid
+ transferid (str): 转账消息里的 transferid
+ transactionid (str): 转账消息里的 transactionid
+
+ Returns:
+ int: 1 为成功,其他失败
+ """
+ req = wcf_pb2.Request()
+ req.func = wcf_pb2.FUNC_RECV_TRANSFER # FUNC_RECV_TRANSFER
+ req.tf.wxid = wxid
+ req.tf.tfid = transferid
+ req.tf.taid = transactionid
+ rsp = self._send_request(req)
+ return rsp.status
+
+ def refresh_pyq(self, id: int = 0) -> int:
+ """刷新朋友圈
+
+ Args:
+ id (int): 开始 id,0 为最新页
+
+ Returns:
+ int: 1 为成功,其他失败
+ """
+ req = wcf_pb2.Request()
+ req.func = wcf_pb2.FUNC_REFRESH_PYQ # FUNC_REFRESH_PYQ
+ req.ui64 = id
+ rsp = self._send_request(req)
+ return rsp.status
+
+ def decrypt_image(self, src: str, dst: str) -> bool:
+ """解密图片:
+
+ Args:
+ src (str): 加密的图片路径
+ dst (str): 解密的图片路径
+
+ Returns:
+ bool: 是否成功
+ """
+ req = wcf_pb2.Request()
+ req.func = wcf_pb2.FUNC_DECRYPT_IMAGE # FUNC_DECRYPT_IMAGE
+ req.dec.src = src
+ req.dec.dst = dst
+ rsp = self._send_request(req)
+ return rsp.status == 1
+
+ def add_chatroom_members(self, roomid: str, wxids: str) -> int:
+ """添加群成员
+
+ Args:
+ roomid (str): 待加群的 id
+ wxids (str): 要加到群里的 wxid,多个用逗号分隔
+
+ Returns:
+ int: 1 为成功,其他失败
+ """
+ req = wcf_pb2.Request()
+ req.func = wcf_pb2.FUNC_ADD_ROOM_MEMBERS # FUNC_ADD_ROOM_MEMBERS
+ req.m.roomid = roomid
+ req.m.wxids = wxids
+ rsp = self._send_request(req)
+ return rsp.status
+
+ def del_chatroom_members(self, roomid: str, wxids: str) -> int:
+ """删除群成员
+
+ Args:
+ roomid (str): 群的 id
+ wxids (str): 要删除成员的 wxid,多个用逗号分隔
+
+ Returns:
+ int: 1 为成功,其他失败
+ """
+ req = wcf_pb2.Request()
+ req.func = wcf_pb2.FUNC_DEL_ROOM_MEMBERS # FUNC_DEL_ROOM_MEMBERS
+ req.m.roomid = roomid
+ req.m.wxids = wxids.replace(" ", "")
+ rsp = self._send_request(req)
+ return rsp.status
+
+ def get_chatroom_members(self, roomid: str) -> Dict:
+ """获取群成员
+
+ Args:
+ roomid (str): 群的 id
+
+ Returns:
+ Dict: 群成员列表: {wxid1: 昵称1, wxid2: 昵称2, ...}
+ """
+ members = {}
+ contacts = self.query_sql("MicroMsg.db", "SELECT UserName, NickName FROM Contact;")
+ contacts = {contact["UserName"]: contact["NickName"]for contact in contacts}
+ crs = self.query_sql("MicroMsg.db", f"SELECT RoomData FROM ChatRoom WHERE ChatRoomName = '{roomid}';")
+ if not crs:
+ return members
+
+ bs = crs[0].get("RoomData")
+ if not bs:
+ return members
+
+ crd = RoomData()
+ crd.ParseFromString(bs)
+ if not bs:
+ return members
+
+ for member in crd.members:
+ members[member.wxid] = member.name if member.name else contacts.get(member.wxid, "")
+
+ return members
+
+ def get_alias_in_chatroom(self, wxid: str, roomid: str) -> str:
+ """获取群名片
+
+ Args:
+ wxid (str): wxid
+ roomid (str): 群的 id
+
+ Returns:
+ str: 群名片
+ """
+ nickname = self.query_sql("MicroMsg.db", f"SELECT NickName FROM Contact WHERE UserName = '{wxid}';")
+ if not nickname:
+ return ""
+
+ nickname = nickname[0].get("NickName", "")
+
+ crs = self.query_sql("MicroMsg.db", f"SELECT RoomData FROM ChatRoom WHERE ChatRoomName = '{roomid}';")
+ if not crs:
+ return ""
+
+ bs = crs[0].get("RoomData")
+ if not bs:
+ return ""
+
+ crd = RoomData()
+ crd.ParseFromString(bs)
+ for member in crd.members:
+ if member.wxid == wxid:
+ return member.name if member.name else nickname
+
+ return ""
diff --git a/clients/wcfautopy/wcferry/event/__init__.py b/clients/wcfautopy/wcferry/event/__init__.py
new file mode 100644
index 0000000..104d964
--- /dev/null
+++ b/clients/wcfautopy/wcferry/event/__init__.py
@@ -0,0 +1,11 @@
+# -*- coding: utf-8 -*-
+
+from wcferry.event.event import Event
+from wcferry.event.core import load_function
+
+Event = load_function(Event)
+
+
+
+
+
diff --git a/clients/wcfautopy/wcferry/event/core.py b/clients/wcfautopy/wcferry/event/core.py
new file mode 100644
index 0000000..7254525
--- /dev/null
+++ b/clients/wcfautopy/wcferry/event/core.py
@@ -0,0 +1,59 @@
+# -*- coding: utf-8 -*-
+
+import traceback
+import asyncio
+from threading import Thread
+
+
+def load_function(cls):
+ cls._add_callback = _add_callback
+ cls._run_func = _run_func
+ return cls
+
+
+def _add_callback(self, func, bot):
+ """
+ 消息处理函数加载器
+ :param func: 消息处理函数
+ :param args: 消息处理函数参数
+ :param kwargs: 消息处理函数参数
+ """
+ if func in self._message_callback_func_list: return
+ self._message_callback_func_list.append(func)
+ self._message_callback_func[func] = bot
+
+
+
+def _run_func(self):
+ """
+ 消息分发器, 将消息发送给所有消息处理函数
+ """
+ try:
+ async_func = []
+ universal_func = []
+ for ele in self._message_callback_func:
+ if asyncio.iscoroutinefunction(ele):
+ async_func.append(ele)
+ else:
+ universal_func.append(ele)
+
+ # 同步函数运行器
+ def run_universal_func():
+ for fn in universal_func:
+ fn(self._message_callback_func[fn], self._message)
+
+ if len(universal_func) != 0: Thread(target=run_universal_func).start()
+ if len(async_func) == 0: return
+
+ # 异步函数运行器
+ async def _run_callback():
+ tasks = [asyncio.create_task(func(self._message_callback_func[func], self._message))
+ for func in async_func]
+ await asyncio.wait(tasks)
+
+ self._loop.run_until_complete(_run_callback())
+ except:
+ traceback.print_exc()
+
+
+
diff --git a/clients/wcfautopy/wcferry/event/event.py b/clients/wcfautopy/wcferry/event/event.py
new file mode 100644
index 0000000..fadb03f
--- /dev/null
+++ b/clients/wcfautopy/wcferry/event/event.py
@@ -0,0 +1,33 @@
+# -*- coding: utf-8 -*-
+
+from abc import abstractmethod
+import asyncio
+import logging
+
+
+class Event(object):
+ _message_callback_func = {}
+ _message_callback_func_list = []
+ _loop = asyncio.get_event_loop()
+
+ def __init__(self):
+ super(Event, self).__init__()
+ self._message = None
+ self._logger: logging = logging.getLogger()
+
+ @abstractmethod
+ def _add_callback(self, func, *args, **kwargs):
+ """
+ 消息处理函数加载器
+ :param func: 消息处理函数
+ :param args: 消息处理函数参数
+ :param kwargs: 消息处理函数参数
+ """
+ raise NotImplementedError
+
+ @abstractmethod
+ def _run_func(self):
+ """
+ 消息分发器, 将消息发送给所有消息处理函数
+ """
+ raise NotImplementedError
diff --git a/clients/wcfautopy/wcferry/roomdata_pb2.py b/clients/wcfautopy/wcferry/roomdata_pb2.py
new file mode 100644
index 0000000..23ab48a
--- /dev/null
+++ b/clients/wcfautopy/wcferry/roomdata_pb2.py
@@ -0,0 +1,27 @@
+# -*- coding: utf-8 -*-
+# Generated by the protocol buffer compiler. DO NOT EDIT!
+# source: roomdata.proto
+"""Generated protocol buffer code."""
+from google.protobuf.internal import builder as _builder
+from google.protobuf import descriptor as _descriptor
+from google.protobuf import descriptor_pool as _descriptor_pool
+from google.protobuf import symbol_database as _symbol_database
+# @@protoc_insertion_point(imports)
+
+_sym_db = _symbol_database.Default()
+
+
+
+
+DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0eroomdata.proto\x12\x0f\x63om.iamteer.wcf\"\xf7\x01\n\x08RoomData\x12\x35\n\x07members\x18\x01 \x03(\x0b\x32$.com.iamteer.wcf.RoomData.RoomMember\x12\x0f\n\x07\x66ield_2\x18\x02 \x01(\x05\x12\x0f\n\x07\x66ield_3\x18\x03 \x01(\x05\x12\x0f\n\x07\x66ield_4\x18\x04 \x01(\x05\x12\x15\n\rroom_capacity\x18\x05 \x01(\x05\x12\x0f\n\x07\x66ield_6\x18\x06 \x01(\x05\x12\x0f\n\x07\x66ield_7\x18\x07 \x01(\x03\x12\x0f\n\x07\x66ield_8\x18\x08 \x01(\x03\x1a\x37\n\nRoomMember\x12\x0c\n\x04wxid\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\r\n\x05state\x18\x03 \x01(\x05\x62\x06proto3')
+
+_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
+_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'roomdata_pb2', globals())
+if _descriptor._USE_C_DESCRIPTORS == False:
+
+ DESCRIPTOR._options = None
+ _ROOMDATA._serialized_start=36
+ _ROOMDATA._serialized_end=283
+ _ROOMDATA_ROOMMEMBER._serialized_start=228
+ _ROOMDATA_ROOMMEMBER._serialized_end=283
+# @@protoc_insertion_point(module_scope)
diff --git a/clients/wcfautopy/wcferry/wcf_pb2.py b/clients/wcfautopy/wcferry/wcf_pb2.py
new file mode 100644
index 0000000..ba142c9
--- /dev/null
+++ b/clients/wcfautopy/wcferry/wcf_pb2.py
@@ -0,0 +1,74 @@
+# -*- coding: utf-8 -*-
+# Generated by the protocol buffer compiler. DO NOT EDIT!
+# source: wcf.proto
+"""Generated protocol buffer code."""
+from google.protobuf.internal import builder as _builder
+from google.protobuf import descriptor as _descriptor
+from google.protobuf import descriptor_pool as _descriptor_pool
+from google.protobuf import symbol_database as _symbol_database
+# @@protoc_insertion_point(imports)
+
+_sym_db = _symbol_database.Default()
+
+
+
+
+DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\twcf.proto\x12\x03wcf\"\xe8\x02\n\x07Request\x12\x1c\n\x04\x66unc\x18\x01 \x01(\x0e\x32\x0e.wcf.Functions\x12\x1b\n\x05\x65mpty\x18\x02 \x01(\x0b\x32\n.wcf.EmptyH\x00\x12\r\n\x03str\x18\x03 \x01(\tH\x00\x12\x1b\n\x03txt\x18\x04 \x01(\x0b\x32\x0c.wcf.TextMsgH\x00\x12\x1c\n\x04\x66ile\x18\x05 \x01(\x0b\x32\x0c.wcf.PathMsgH\x00\x12\x1d\n\x05query\x18\x06 \x01(\x0b\x32\x0c.wcf.DbQueryH\x00\x12\x1e\n\x01v\x18\x07 \x01(\x0b\x32\x11.wcf.VerificationH\x00\x12\x1c\n\x01m\x18\x08 \x01(\x0b\x32\x0f.wcf.AddMembersH\x00\x12\x1a\n\x03xml\x18\t \x01(\x0b\x32\x0b.wcf.XmlMsgH\x00\x12\x1b\n\x03\x64\x65\x63\x18\n \x01(\x0b\x32\x0c.wcf.DecPathH\x00\x12\x1b\n\x02tf\x18\x0b \x01(\x0b\x32\r.wcf.TransferH\x00\x12\x0e\n\x04ui64\x18\x0c \x01(\x04H\x00\x12\x0e\n\x04\x66lag\x18\r \x01(\x08H\x00\x42\x05\n\x03msg\"\xab\x02\n\x08Response\x12\x1c\n\x04\x66unc\x18\x01 \x01(\x0e\x32\x0e.wcf.Functions\x12\x10\n\x06status\x18\x02 \x01(\x05H\x00\x12\r\n\x03str\x18\x03 \x01(\tH\x00\x12\x1b\n\x05wxmsg\x18\x04 \x01(\x0b\x32\n.wcf.WxMsgH\x00\x12\x1e\n\x05types\x18\x05 \x01(\x0b\x32\r.wcf.MsgTypesH\x00\x12$\n\x08\x63ontacts\x18\x06 \x01(\x0b\x32\x10.wcf.RpcContactsH\x00\x12\x1b\n\x03\x64\x62s\x18\x07 \x01(\x0b\x32\x0c.wcf.DbNamesH\x00\x12\x1f\n\x06tables\x18\x08 \x01(\x0b\x32\r.wcf.DbTablesH\x00\x12\x1b\n\x04rows\x18\t \x01(\x0b\x32\x0b.wcf.DbRowsH\x00\x12\x1b\n\x02ui\x18\n \x01(\x0b\x32\r.wcf.UserInfoH\x00\x42\x05\n\x03msg\"\x07\n\x05\x45mpty\"\xba\x01\n\x05WxMsg\x12\x0f\n\x07is_self\x18\x01 \x01(\x08\x12\x10\n\x08is_group\x18\x02 \x01(\x08\x12\n\n\x02id\x18\x03 \x01(\x04\x12\x0c\n\x04type\x18\x04 \x01(\r\x12\n\n\x02ts\x18\x05 \x01(\r\x12\x0e\n\x06roomid\x18\x06 \x01(\t\x12\x0f\n\x07\x63ontent\x18\x07 \x01(\t\x12\x0e\n\x06sender\x18\x08 \x01(\t\x12\x0c\n\x04sign\x18\t \x01(\t\x12\r\n\x05thumb\x18\n \x01(\t\x12\r\n\x05\x65xtra\x18\x0b \x01(\t\x12\x0b\n\x03xml\x18\x0c \x01(\t\"7\n\x07TextMsg\x12\x0b\n\x03msg\x18\x01 \x01(\t\x12\x10\n\x08receiver\x18\x02 \x01(\t\x12\r\n\x05\x61ters\x18\x03 \x01(\t\")\n\x07PathMsg\x12\x0c\n\x04path\x18\x01 \x01(\t\x12\x10\n\x08receiver\x18\x02 \x01(\t\"G\n\x06XmlMsg\x12\x10\n\x08receiver\x18\x01 \x01(\t\x12\x0f\n\x07\x63ontent\x18\x02 \x01(\t\x12\x0c\n\x04path\x18\x03 \x01(\t\x12\x0c\n\x04type\x18\x04 \x01(\x05\"a\n\x08MsgTypes\x12\'\n\x05types\x18\x01 \x03(\x0b\x32\x18.wcf.MsgTypes.TypesEntry\x1a,\n\nTypesEntry\x12\x0b\n\x03key\x18\x01 \x01(\x05\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\x87\x01\n\nRpcContact\x12\x0c\n\x04wxid\x18\x01 \x01(\t\x12\x0c\n\x04\x63ode\x18\x02 \x01(\t\x12\x0e\n\x06remark\x18\x03 \x01(\t\x12\x0c\n\x04name\x18\x04 \x01(\t\x12\x0f\n\x07\x63ountry\x18\x05 \x01(\t\x12\x10\n\x08province\x18\x06 \x01(\t\x12\x0c\n\x04\x63ity\x18\x07 \x01(\t\x12\x0e\n\x06gender\x18\x08 \x01(\x05\"0\n\x0bRpcContacts\x12!\n\x08\x63ontacts\x18\x01 \x03(\x0b\x32\x0f.wcf.RpcContact\"\x18\n\x07\x44\x62Names\x12\r\n\x05names\x18\x01 \x03(\t\"$\n\x07\x44\x62Table\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x0b\n\x03sql\x18\x02 \x01(\t\"(\n\x08\x44\x62Tables\x12\x1c\n\x06tables\x18\x01 \x03(\x0b\x32\x0c.wcf.DbTable\"\"\n\x07\x44\x62Query\x12\n\n\x02\x64\x62\x18\x01 \x01(\t\x12\x0b\n\x03sql\x18\x02 \x01(\t\"8\n\x07\x44\x62\x46ield\x12\x0c\n\x04type\x18\x01 \x01(\x05\x12\x0e\n\x06\x63olumn\x18\x02 \x01(\t\x12\x0f\n\x07\x63ontent\x18\x03 \x01(\x0c\"%\n\x05\x44\x62Row\x12\x1c\n\x06\x66ields\x18\x01 \x03(\x0b\x32\x0c.wcf.DbField\"\"\n\x06\x44\x62Rows\x12\x18\n\x04rows\x18\x01 \x03(\x0b\x32\n.wcf.DbRow\"5\n\x0cVerification\x12\n\n\x02v3\x18\x01 \x01(\t\x12\n\n\x02v4\x18\x02 \x01(\t\x12\r\n\x05scene\x18\x03 \x01(\x05\"+\n\nAddMembers\x12\x0e\n\x06roomid\x18\x01 \x01(\t\x12\r\n\x05wxids\x18\x02 \x01(\t\"D\n\x08UserInfo\x12\x0c\n\x04wxid\x18\x01 \x01(\t\x12\x0c\n\x04name\x18\x02 \x01(\t\x12\x0e\n\x06mobile\x18\x03 \x01(\t\x12\x0c\n\x04home\x18\x04 \x01(\t\"#\n\x07\x44\x65\x63Path\x12\x0b\n\x03src\x18\x01 \x01(\t\x12\x0b\n\x03\x64st\x18\x02 \x01(\t\"4\n\x08Transfer\x12\x0c\n\x04wxid\x18\x01 \x01(\t\x12\x0c\n\x04tfid\x18\x02 \x01(\t\x12\x0c\n\x04taid\x18\x03 \x01(\t*\x84\x04\n\tFunctions\x12\x11\n\rFUNC_RESERVED\x10\x00\x12\x11\n\rFUNC_IS_LOGIN\x10\x01\x12\x16\n\x12\x46UNC_GET_SELF_WXID\x10\x10\x12\x16\n\x12\x46UNC_GET_MSG_TYPES\x10\x11\x12\x15\n\x11\x46UNC_GET_CONTACTS\x10\x12\x12\x15\n\x11\x46UNC_GET_DB_NAMES\x10\x13\x12\x16\n\x12\x46UNC_GET_DB_TABLES\x10\x14\x12\x16\n\x12\x46UNC_GET_USER_INFO\x10\x15\x12\x11\n\rFUNC_SEND_TXT\x10 \x12\x11\n\rFUNC_SEND_IMG\x10!\x12\x12\n\x0e\x46UNC_SEND_FILE\x10\"\x12\x11\n\rFUNC_SEND_XML\x10#\x12\x15\n\x11\x46UNC_SEND_EMOTION\x10$\x12\x18\n\x14\x46UNC_ENABLE_RECV_TXT\x10\x30\x12\x19\n\x15\x46UNC_DISABLE_RECV_TXT\x10@\x12\x16\n\x12\x46UNC_EXEC_DB_QUERY\x10P\x12\x16\n\x12\x46UNC_ACCEPT_FRIEND\x10Q\x12\x16\n\x12\x46UNC_RECV_TRANSFER\x10R\x12\x14\n\x10\x46UNC_REFRESH_PYQ\x10S\x12\x16\n\x12\x46UNC_DECRYPT_IMAGE\x10`\x12\x19\n\x15\x46UNC_ADD_ROOM_MEMBERS\x10p\x12\x19\n\x15\x46UNC_DEL_ROOM_MEMBERS\x10qB\r\n\x0b\x63om.iamteerb\x06proto3')
+
+_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals())
+_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'wcf_pb2', globals())
+if _descriptor._USE_C_DESCRIPTORS == False:
+
+ DESCRIPTOR._options = None
+ DESCRIPTOR._serialized_options = b'\n\013com.iamteer'
+ _MSGTYPES_TYPESENTRY._options = None
+ _MSGTYPES_TYPESENTRY._serialized_options = b'8\001'
+ _FUNCTIONS._serialized_start=1878
+ _FUNCTIONS._serialized_end=2394
+ _REQUEST._serialized_start=19
+ _REQUEST._serialized_end=379
+ _RESPONSE._serialized_start=382
+ _RESPONSE._serialized_end=681
+ _EMPTY._serialized_start=683
+ _EMPTY._serialized_end=690
+ _WXMSG._serialized_start=693
+ _WXMSG._serialized_end=879
+ _TEXTMSG._serialized_start=881
+ _TEXTMSG._serialized_end=936
+ _PATHMSG._serialized_start=938
+ _PATHMSG._serialized_end=979
+ _XMLMSG._serialized_start=981
+ _XMLMSG._serialized_end=1052
+ _MSGTYPES._serialized_start=1054
+ _MSGTYPES._serialized_end=1151
+ _MSGTYPES_TYPESENTRY._serialized_start=1107
+ _MSGTYPES_TYPESENTRY._serialized_end=1151
+ _RPCCONTACT._serialized_start=1154
+ _RPCCONTACT._serialized_end=1289
+ _RPCCONTACTS._serialized_start=1291
+ _RPCCONTACTS._serialized_end=1339
+ _DBNAMES._serialized_start=1341
+ _DBNAMES._serialized_end=1365
+ _DBTABLE._serialized_start=1367
+ _DBTABLE._serialized_end=1403
+ _DBTABLES._serialized_start=1405
+ _DBTABLES._serialized_end=1445
+ _DBQUERY._serialized_start=1447
+ _DBQUERY._serialized_end=1481
+ _DBFIELD._serialized_start=1483
+ _DBFIELD._serialized_end=1539
+ _DBROW._serialized_start=1541
+ _DBROW._serialized_end=1578
+ _DBROWS._serialized_start=1580
+ _DBROWS._serialized_end=1614
+ _VERIFICATION._serialized_start=1616
+ _VERIFICATION._serialized_end=1669
+ _ADDMEMBERS._serialized_start=1671
+ _ADDMEMBERS._serialized_end=1714
+ _USERINFO._serialized_start=1716
+ _USERINFO._serialized_end=1784
+ _DECPATH._serialized_start=1786
+ _DECPATH._serialized_end=1821
+ _TRANSFER._serialized_start=1823
+ _TRANSFER._serialized_end=1875
+# @@protoc_insertion_point(module_scope)
diff --git a/clients/wcfautopy/wcferry/wxmsg.py b/clients/wcfautopy/wcferry/wxmsg.py
new file mode 100644
index 0000000..122ec7c
--- /dev/null
+++ b/clients/wcfautopy/wcferry/wxmsg.py
@@ -0,0 +1,123 @@
+# -*- coding: utf-8 -*-
+
+import re
+from datetime import datetime
+import time
+from wcferry import wcf_pb2
+
+
+class WxMsg(dict):
+ """微信消息
+
+ Attributes:
+ type (int): 消息类型,可通过 `get_msg_types` 获取
+ id (str): 消息 id
+ xml (str): 消息 xml 部分
+ sender (str): 消息发送人
+ roomid (str): (仅群消息有)群 id
+ content (str): 消息内容
+ thumb (str): 视频或图片消息的缩略图路径
+ extra (str): 视频或图片消息的路径
+ """
+
+ def __init__(self, msg: wcf_pb2.WxMsg) -> None:
+ super(WxMsg, self).__init__()
+ self._is_self = msg.is_self
+ self._is_group = msg.is_group
+ self._type = msg.type
+ self._id = msg.id
+ self._ts = msg.ts
+ self._sign = msg.sign
+ self._xml = msg.xml
+ self._sender = msg.sender
+ self._roomid = msg.roomid
+ self._content = msg.content
+ self._thumb = msg.thumb
+ self._extra = msg.extra
+ self.__data = {'isSelf': True if self._is_self else False,
+ 'isGroup': True if self._is_group else False,
+ 'isPyq': True if self._type == 0 else False,
+ 'data': {
+ 'type': self._type,
+ 'content': self._content,
+ 'sender': self._sender,
+ 'msgid': self._id,
+ 'roomid': self._roomid if self._roomid else None,
+ 'xml': self._xml,
+ 'thumb': self._thumb if self._thumb else None,
+ 'extra': self._extra if self._extra else None,
+ 'time': int(time.time() * 1000),
+ }, 'revokmsgid': None, 'isRevokeMsg': False, }
+ self.__revokmsg_p()
+
+ def __revokmsg_p(self):
+ rmsg = self.__data['data']['content']
+ rev_type = re.findall('", rmsg)
+ if len(rev_type) == 0 or len(rev_w) == 0: return
+ if rev_type[0] == 'revokemsg' and rev_w[0] == '你撤回了一条消息':
+ self.__data['data']['content'] = rev_w[0]
+ self.__data['isRevokeMsg'] = True
+ self.__data['revokmsgid'] = re.findall('(.*?)', rmsg)[0]
+
+ def __str__(self) -> str:
+ return repr(self.__data)
+
+ def __repr__(self) -> str:
+ return repr(self.__data)
+
+ def __getitem__(self, key):
+ return self.__data[key]
+
+ def __getattr__(self, item):
+ if item in ['content', 'sender', 'roomid', 'xml', 'thumb', 'extra', 'type']:
+ return self.__data['data'][item]
+ if item == 'id':
+ return self.__data['data']['msgid']
+ if item == 'ts':
+ return self._ts
+ if item == 'sign':
+ return self._sign
+
+ def __setitem__(self, key, value):
+ self.__data[key] = value
+
+ def is_image(self) -> bool:
+ """是否是图片"""
+ return self.type == 3 and ('imgdatahash' in self.__data['data']['content'])
+
+ def is_voice(self) -> bool:
+ """是否是语音"""
+ return self.type == 34 and ('voicemsg' in self.__data['data']['content'])
+
+ def is_video(self) -> bool:
+ """是否是视频"""
+ return self.type == 43 and ('videomsg' in self.__data['data']['content'])
+
+ def is_pyq(self) -> bool:
+ return self.type == 0
+
+ def from_self(self) -> bool:
+ """是否自己发的消息"""
+ return self._is_self == 1
+
+ def from_group(self) -> bool:
+ """是否群聊消息"""
+ return self._is_group
+
+ def is_at(self, wxid) -> bool:
+ """是否被 @:群消息,在 @ 名单里,并且不是 @ 所有人"""
+ if not self.from_group():
+ return False # 只有群消息才能 @
+
+ if not re.findall(f".*({wxid}).*", self.xml):
+ return False # 不在 @ 清单里
+
+ if re.findall(r"@(?:所有人|all|All)", self.content):
+ return False # 排除 @ 所有人
+
+ return True
+
+ def is_text(self) -> bool:
+ """是否文本消息"""
+ return self.type == 1