WeChatFerry/clients/python/wcferry/client.py

1007 lines
32 KiB
Python
Raw Normal View History

2022-10-16 16:50:22 +08:00
#! /usr/bin/env python3
# -*- coding: utf-8 -*-
2025-03-24 22:50:15 +08:00
__version__ = "39.4.4.0"
2023-05-04 21:34:04 +08:00
2022-10-16 16:50:22 +08:00
import atexit
2023-02-25 21:58:00 +08:00
import base64
2024-04-24 19:18:13 +08:00
import ctypes
2022-10-16 16:50:22 +08:00
import logging
2023-07-31 23:11:13 +08:00
import mimetypes
2022-10-16 16:50:22 +08:00
import os
2022-10-16 21:47:13 +08:00
import re
2025-03-10 23:03:53 +08:00
import shutil
2025-03-08 11:57:08 +08:00
import subprocess
2022-10-16 16:50:22 +08:00
import sys
2023-02-27 23:36:17 +08:00
from queue import Queue
2022-10-16 16:50:22 +08:00
from threading import Thread
from time import sleep
2023-09-21 22:46:26 +08:00
from typing import Callable, Dict, List, Optional
2022-10-16 16:50:22 +08:00
2023-02-25 21:58:00 +08:00
import pynng
2023-05-05 13:18:54 +08:00
import requests
2023-02-25 21:58:00 +08:00
from google.protobuf import json_format
2023-05-04 21:34:04 +08:00
from wcferry import wcf_pb2
2024-11-03 17:03:02 +08:00
from wcferry.wcf_pb2 import RoomData
2023-05-04 21:34:04 +08:00
from wcferry.wxmsg import WxMsg
2022-10-19 18:46:27 +08:00
2022-10-16 16:50:22 +08:00
2023-03-02 21:25:50 +08:00
def _retry():
def decorator(func):
""" Retry the function """
def wrapper(*args, **kwargs):
2023-04-09 11:29:12 +08:00
def logerror(e):
2023-04-09 10:27:37 +08:00
func_name = re.findall(r"func: (.*?)\n", str(args[1]))[-1]
logging.getLogger("WCF").error(f"Call {func_name} failed: {e}")
2023-03-02 21:25:50 +08:00
try:
ret = func(*args, **kwargs)
2023-04-09 10:27:37 +08:00
except pynng.Timeout as _: # 如果超时,重试
2023-03-02 21:25:50 +08:00
try:
ret = func(*args, **kwargs)
except Exception as e:
2023-04-09 11:29:12 +08:00
logerror(e)
2023-03-04 22:51:08 +08:00
ret = wcf_pb2.Response()
2023-04-09 11:29:12 +08:00
except Exception as e: # 其他异常,退出
2023-11-26 19:40:57 +08:00
logerror(f"Exiting... {e}")
2023-04-09 10:27:37 +08:00
sys.exit(-1)
2023-03-02 21:25:50 +08:00
return ret
return wrapper
return decorator
2022-10-16 16:50:22 +08:00
class Wcf():
2023-05-04 23:07:46 +08:00
"""WeChatFerry, 一个玩微信的工具。
Args:
host (str): `wcferry` RPC 服务器地址默认本地启动也可以指定地址连接远程服务
port (int): `wcferry` RPC 服务器端口默认为 10086接收消息会占用 `port+1` 端口
debug (bool): 是否开启调试模式仅本地启动有效
2024-02-18 16:13:16 +08:00
block (bool): 是否阻塞等待微信登录不阻塞的话可以手动获取登录二维码主动登录
2023-05-04 23:07:46 +08:00
Attributes:
contacts (list): 联系人缓存调用 `get_contacts` 后更新
"""
2022-10-16 21:47:13 +08:00
2024-02-18 16:13:16 +08:00
def __init__(self, host: str = None, port: int = 10086, debug: bool = True, block: bool = True) -> None:
2023-05-05 13:18:54 +08:00
self._local_mode = False
self._is_running = False
2023-02-25 21:58:00 +08:00
self._is_receiving_msg = False
2023-05-04 21:34:04 +08:00
self._wcf_root = os.path.abspath(os.path.dirname(__file__))
2023-07-31 23:11:13 +08:00
self._dl_path = f"{self._wcf_root}/.dl"
os.makedirs(self._dl_path, exist_ok=True)
2022-10-16 16:50:22 +08:00
self.LOG = logging.getLogger("WCF")
2025-03-08 11:57:08 +08:00
self._set_console_utf8()
2023-03-05 01:53:20 +08:00
self.LOG.info(f"wcferry version: {__version__}")
2023-04-09 11:29:12 +08:00
self.port = port
self.host = host
2024-04-24 19:18:13 +08:00
self.sdk = None
2023-04-09 11:29:12 +08:00
if host is None:
2023-05-05 13:18:54 +08:00
self._local_mode = True
2023-04-09 11:29:12 +08:00
self.host = "127.0.0.1"
2024-04-24 19:18:13 +08:00
self.sdk = ctypes.cdll.LoadLibrary(f"{self._wcf_root}/sdk.dll")
if self.sdk.WxInitSDK(debug, port) != 0:
self.LOG.error("初始化失败!")
2023-04-09 10:27:37 +08:00
os._exit(-1)
2023-04-09 11:29:12 +08:00
self.cmd_url = f"tcp://{self.host}:{self.port}"
2023-02-25 21:58:00 +08:00
# 连接 RPC
self.cmd_socket = pynng.Pair1() # Client --> Server发送消息
2023-11-26 19:40:57 +08:00
self.cmd_socket.send_timeout = 5000 # 发送 5 秒超时
self.cmd_socket.recv_timeout = 5000 # 接收 5 秒超时
2023-04-09 11:29:12 +08:00
try:
self.cmd_socket.dial(self.cmd_url, block=True)
except Exception as e:
self.LOG.error(f"连接失败: {e}")
os._exit(-2)
2023-02-25 21:58:00 +08:00
self.msg_socket = pynng.Pair1() # Server --> Client接收消息
2023-11-26 19:40:57 +08:00
self.msg_socket.send_timeout = 5000 # 发送 5 秒超时
self.msg_socket.recv_timeout = 5000 # 接收 5 秒超时
2023-04-09 11:29:12 +08:00
self.msg_url = self.cmd_url.replace(str(self.port), str(self.port + 1))
2023-02-25 21:58:00 +08:00
2023-04-09 11:29:12 +08:00
atexit.register(self.cleanup) # 退出的时候停止消息接收,防止资源占用
2022-10-16 16:50:22 +08:00
self._is_running = True
2022-10-16 21:47:13 +08:00
self.contacts = []
2023-02-27 23:36:17 +08:00
self.msgQ = Queue()
2022-10-16 21:47:13 +08:00
self._SQL_TYPES = {1: int, 2: float, 3: lambda x: x.decode("utf-8"), 4: bytes, 5: lambda x: None}
2024-02-25 15:03:12 +08:00
self.self_wxid = ""
2024-02-18 16:13:16 +08:00
if block:
self.LOG.info("等待微信登录...")
while not self.is_login(): # 等待微信登录成功
sleep(1)
2024-02-25 15:03:12 +08:00
self.self_wxid = self.get_self_wxid()
2022-10-16 16:50:22 +08:00
def __del__(self) -> None:
self.cleanup()
2025-03-08 11:57:08 +08:00
def _set_console_utf8(self):
2025-02-12 00:53:20 +08:00
try:
2025-03-08 11:57:08 +08:00
subprocess.run("chcp 65001", shell=True, check=True,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except subprocess.CalledProcessError as e:
self.LOG.error(f"修改控制台代码页失败: {e}")
2025-02-12 00:53:20 +08:00
2022-10-16 16:50:22 +08:00
def cleanup(self) -> None:
2023-02-25 21:58:00 +08:00
"""关闭连接,回收资源"""
2022-10-16 16:50:22 +08:00
if not self._is_running:
return
self.disable_recv_msg()
2023-02-25 21:58:00 +08:00
self.cmd_socket.close()
2024-04-24 19:18:13 +08:00
if self._local_mode and self.sdk and self.sdk.WxDestroySDK() != 0:
self.LOG.error("退出失败!")
2022-10-16 16:50:22 +08:00
self._is_running = False
def keep_running(self):
2022-10-16 21:47:13 +08:00
"""阻塞进程,让 RPC 一直维持连接"""
2022-10-16 16:50:22 +08:00
try:
while True:
sleep(1)
except Exception as e:
self.cleanup()
2023-03-02 21:25:50 +08:00
@_retry()
2023-02-25 21:58:00 +08:00
def _send_request(self, req: wcf_pb2.Request) -> wcf_pb2.Response:
data = req.SerializeToString()
self.cmd_socket.send(data)
rsp = wcf_pb2.Response()
bs = self.cmd_socket.recv_msg().bytes
self.LOG.debug(bs.hex())
rsp.ParseFromString(bs)
2023-02-25 21:58:00 +08:00
return rsp
2023-02-27 23:36:17 +08:00
def is_receiving_msg(self) -> bool:
2023-05-04 23:07:46 +08:00
"""是否已启动接收消息功能"""
2023-02-27 23:36:17 +08:00
return self._is_receiving_msg
2024-02-18 16:13:16 +08:00
def get_qrcode(self) -> str:
"""获取登录二维码,已经登录则返回空字符串"""
req = wcf_pb2.Request()
req.func = wcf_pb2.FUNC_REFRESH_QRCODE # FUNC_REFRESH_QRCODE
rsp = self._send_request(req)
return rsp.str
2023-02-25 21:58:00 +08:00
def is_login(self) -> bool:
2022-10-16 21:47:13 +08:00
"""是否已经登录"""
2023-02-25 21:58:00 +08:00
req = wcf_pb2.Request()
req.func = wcf_pb2.FUNC_IS_LOGIN # FUNC_IS_LOGIN
rsp = self._send_request(req)
return rsp.status == 1
2022-10-16 16:50:22 +08:00
def get_self_wxid(self) -> str:
2022-10-16 21:47:13 +08:00
"""获取登录账户的 wxid"""
2023-02-25 21:58:00 +08:00
req = wcf_pb2.Request()
req.func = wcf_pb2.FUNC_GET_SELF_WXID # FUNC_GET_SELF_WXID
rsp = self._send_request(req)
2022-10-16 16:50:22 +08:00
return rsp.str
2023-09-21 22:46:26 +08:00
def get_msg_types(self) -> Dict:
2023-02-25 21:58:00 +08:00
"""获取所有消息类型"""
req = wcf_pb2.Request()
req.func = wcf_pb2.FUNC_GET_MSG_TYPES # FUNC_GET_MSG_TYPES
rsp = self._send_request(req)
2023-03-01 19:38:18 +08:00
types = json_format.MessageToDict(rsp.types).get("types", {})
2023-02-25 21:58:00 +08:00
types = {int(k): v for k, v in types.items()}
return dict(sorted(dict(types).items()))
2023-09-21 22:46:26 +08:00
def get_contacts(self) -> List[Dict]:
2023-02-25 21:58:00 +08:00
"""获取完整通讯录"""
req = wcf_pb2.Request()
req.func = wcf_pb2.FUNC_GET_CONTACTS # FUNC_GET_CONTACTS
rsp = self._send_request(req)
2023-03-01 19:38:18 +08:00
contacts = json_format.MessageToDict(rsp.contacts).get("contacts", [])
2023-02-25 21:58:00 +08:00
2023-07-11 22:58:12 +08:00
self.contacts.clear()
2023-02-25 21:58:00 +08:00
for cnt in contacts:
gender = cnt.get("gender", "")
if gender == 1:
gender = ""
elif gender == 2:
gender = ""
2023-06-25 10:29:03 +08:00
else:
gender = ""
2023-02-25 21:58:00 +08:00
contact = {
"wxid": cnt.get("wxid", ""),
"code": cnt.get("code", ""),
2023-04-13 00:06:08 +08:00
"remark": cnt.get("remark", ""),
2023-02-25 21:58:00 +08:00
"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)
2023-03-01 19:38:18 +08:00
dbs = json_format.MessageToDict(rsp.dbs).get("names", [])
2023-02-25 21:58:00 +08:00
return dbs
2023-09-21 22:46:26 +08:00
def get_tables(self, db: str) -> List[Dict]:
2023-05-04 23:07:46 +08:00
"""获取 db 中所有表
Args:
db (str): 数据库名可通过 `get_dbs` 查询
Returns:
2023-09-21 22:46:26 +08:00
List[Dict]: `db` 下的所有表名及对应建表语句
2023-05-04 23:07:46 +08:00
"""
2023-02-25 21:58:00 +08:00
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
2023-09-21 22:46:26 +08:00
def get_user_info(self) -> Dict:
2023-05-04 23:07:46 +08:00
"""获取登录账号个人信息"""
2023-04-11 23:26:38 +08:00
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
2023-12-04 06:56:43 +08:00
def get_audio_msg(self, id: int, dir: str, timeout: int = 3) -> str:
2023-12-03 16:35:42 +08:00
"""获取语音消息并转成 MP3
Args:
id (int): 语音消息 id
dir (str): MP3 保存目录目录不存在会出错
2023-12-04 06:56:43 +08:00
timeout (int): 超时时间
2023-12-03 16:35:42 +08:00
Returns:
str: 成功返回存储路径空字符串为失败原因见日志
"""
2023-12-04 06:56:43 +08:00
def _get_audio_msg(id, dir):
req = wcf_pb2.Request()
req.func = wcf_pb2.FUNC_GET_AUDIO_MSG # FUNC_GET_AUDIO_MSG
req.am.id = id
req.am.dir = dir
rsp = self._send_request(req)
return rsp.str
if timeout == 0:
return _get_audio_msg(id, dir)
cnt = 0
while cnt < timeout:
path = _get_audio_msg(id, dir)
if path:
return path
sleep(1)
cnt += 1
2023-12-03 16:35:42 +08:00
2023-12-04 06:56:43 +08:00
self.LOG.error(f"获取超时")
return ""
2023-12-03 16:35:42 +08:00
2023-02-25 21:58:00 +08:00
def send_text(self, msg: str, receiver: str, aters: Optional[str] = "") -> int:
2023-05-04 23:07:46 +08:00
"""发送文本消息
Args:
2023-07-15 12:13:40 +08:00
msg (str): 要发送的消息换行使用 `\\\\n` 单杠如果 @ 人的话需要带上跟 `aters` 里数量相同的 @
2023-05-04 23:07:46 +08:00
receiver (str): 消息接收人wxid 或者 roomid
2023-07-12 23:22:42 +08:00
aters (str): @ wxid多个用逗号分隔`@所有人` 只需要 `notify@all`
2023-05-04 23:07:46 +08:00
Returns:
int: 0 为成功其他失败
"""
2023-02-25 21:58:00 +08:00
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
2023-07-31 23:11:13 +08:00
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
2023-02-25 21:58:00 +08:00
def send_image(self, path: str, receiver: str) -> int:
2023-05-05 13:18:54 +08:00
"""发送图片,非线程安全
2023-05-04 23:07:46 +08:00
Args:
2023-07-31 23:11:13 +08:00
path (str): 图片路径`C:/Projs/WeChatRobot/TEQuant.jpeg` `https://raw.githubusercontent.com/lich0821/WeChatFerry/master/assets/TEQuant.jpg`
2023-05-04 23:07:46 +08:00
receiver (str): 消息接收人wxid 或者 roomid
Returns:
int: 0 为成功其他失败
"""
2023-07-31 23:11:13 +08:00
path = self._process_path(path)
if isinstance(path, int):
return path
2023-05-05 13:18:54 +08:00
2023-02-25 21:58:00 +08:00
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:
2023-07-31 23:11:13 +08:00
"""发送文件,非线程安全
2023-05-04 23:07:46 +08:00
Args:
2023-07-31 23:11:13 +08:00
path (str): 本地文件路径`C:/Projs/WeChatRobot/README.MD` `https://raw.githubusercontent.com/lich0821/WeChatFerry/master/README.MD`
2023-05-04 23:07:46 +08:00
receiver (str): 消息接收人wxid 或者 roomid
Returns:
int: 0 为成功其他失败
"""
2023-07-31 23:11:13 +08:00
path = self._process_path(path)
if isinstance(path, int):
return path
2023-02-25 21:58:00 +08:00
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
2022-10-16 16:50:22 +08:00
2023-03-01 04:03:53 +08:00
def send_xml(self, receiver: str, xml: str, type: int, path: str = None) -> int:
2023-05-04 23:07:46 +08:00
"""发送 XML
Args:
receiver (str): 消息接收人wxid 或者 roomid
xml (str): xml 内容
type (int): xml 类型0x21 为小程序
path (str): 封面图片路径
Returns:
int: 0 为成功其他失败
"""
2023-03-01 04:03:53 +08:00
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
2023-03-13 00:09:32 +08:00
def send_emotion(self, path: str, receiver: str) -> int:
2023-05-04 23:07:46 +08:00
"""发送表情
Args:
path (str): 本地表情路径`C:/Projs/WeChatRobot/emo.gif`
receiver (str): 消息接收人wxid 或者 roomid
Returns:
int: 0 为成功其他失败
"""
2023-03-13 00:09:32 +08:00
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
2023-12-05 23:37:57 +08:00
def send_rich_text(
self, name: str, account: str, title: str, digest: str, url: str, thumburl: str, receiver: str) -> int:
"""发送富文本消息
卡片样式
|-------------------------------------|
|title, 最长两行
|(长标题, 标题短的话这行没有)
|digest, 最多三行会占位 |--------|
|digest, 最多三行会占位 |thumburl|
|digest, 最多三行会占位 |--------|
|(account logo) name
|-------------------------------------|
Args:
name (str): 左下显示的名字
account (str): 填公众号 id 可以显示对应的头像gh_ 开头的
title (str): 标题最多两行
digest (str): 摘要三行
url (str): 点击后跳转的链接
thumburl (str): 缩略图的链接
receiver (str): 接收人, wxid 或者 roomid
Returns:
int: 0 为成功其他失败
"""
req = wcf_pb2.Request()
req.func = wcf_pb2.FUNC_SEND_RICH_TXT # FUNC_SEND_RICH_TXT
req.rt.name = name
req.rt.account = account
req.rt.title = title
req.rt.digest = digest
req.rt.url = url
req.rt.thumburl = thumburl
req.rt.receiver = receiver
rsp = self._send_request(req)
return rsp.status
2023-12-06 09:21:55 +08:00
def send_pat_msg(self, roomid: str, wxid: str) -> int:
"""拍一拍群友
Args:
roomid (str): id
wxid (str): 要拍的群友的 wxid
Returns:
int: 1 为成功其他失败
"""
req = wcf_pb2.Request()
req.func = wcf_pb2.FUNC_SEND_PAT_MSG # FUNC_SEND_PAT_MSG
req.pm.roomid = roomid
req.pm.wxid = wxid
rsp = self._send_request(req)
return rsp.status
2023-12-17 19:09:27 +08:00
def forward_msg(self, id: int, receiver: str) -> int:
"""转发消息。可以转发文本、图片、表情、甚至各种 XML
语音也行不过效果嘛自己验证吧
Args:
id (str): 待转发消息的 id
receiver (str): 消息接收者wxid 或者 roomid
Returns:
int: 1 为成功其他失败
"""
req = wcf_pb2.Request()
req.func = wcf_pb2.FUNC_FORWARD_MSG # FUNC_FORWARD_MSG
req.fm.id = id
req.fm.receiver = receiver
rsp = self._send_request(req)
return rsp.status
2023-02-27 23:36:17 +08:00
def get_msg(self, block=True) -> WxMsg:
2023-05-04 23:07:46 +08:00
"""从消息队列中获取消息
Args:
block (bool): 是否阻塞默认阻塞
Returns:
WxMsg: 微信消息
Raises:
Empty: 如果阻塞并且超时抛出空异常需要用户自行捕获
"""
2023-02-27 23:36:17 +08:00
return self.msgQ.get(block, timeout=1)
2023-07-16 18:35:53 +08:00
def enable_receiving_msg(self, pyq=False) -> bool:
2023-05-04 23:07:46 +08:00
"""允许接收消息,成功后通过 `get_msg` 读取消息"""
2023-02-27 23:36:17 +08:00
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:
2023-05-04 21:34:04 +08:00
self.msgQ.put(WxMsg(rsp.wxmsg))
2023-02-27 23:36:17 +08:00
# 退出前关闭通信通道
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
2023-07-16 18:35:53 +08:00
req.flag = pyq
2023-02-27 23:36:17 +08:00
rsp = self._send_request(req)
if rsp.status != 0:
return False
self._is_receiving_msg = True
# 阻塞,把控制权交给用户
2023-07-16 18:35:53 +08:00
# self.listening_msg(callback)
2023-02-27 23:36:17 +08:00
# 不阻塞,启动一个新的线程来接收消息
Thread(target=listening_msg, name="GetMessage", daemon=True).start()
return True
2022-10-16 21:47:13 +08:00
def enable_recv_msg(self, callback: Callable[[WxMsg], None] = None) -> bool:
2023-05-04 23:07:46 +08:00
"""(不建议使用)设置接收消息回调,消息量大时可能会丢失消息
.. deprecated:: 3.7.0.30.13
"""
2023-02-25 21:58:00 +08:00
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:
2023-05-04 21:34:04 +08:00
callback(WxMsg(rsp.wxmsg))
2023-02-25 21:58:00 +08:00
# 退出前关闭通信通道
self.msg_socket.close()
if self._is_receiving_msg:
2022-10-16 16:50:22 +08:00
return True
if callback is None:
return False
2023-02-25 21:58:00 +08:00
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
2022-10-16 16:50:22 +08:00
# 阻塞,把控制权交给用户
2023-02-27 23:36:17 +08:00
# listening_msg()
2022-10-16 16:50:22 +08:00
# 不阻塞,启动一个新的线程来接收消息
2023-02-25 21:58:00 +08:00
Thread(target=listening_msg, name="GetMessage", daemon=True).start()
2022-10-16 16:50:22 +08:00
return True
def disable_recv_msg(self) -> int:
2022-10-16 21:47:13 +08:00
"""停止接收消息"""
2023-02-25 21:58:00 +08:00
if not self._is_receiving_msg:
return 0
2022-10-16 16:50:22 +08:00
2023-02-25 21:58:00 +08:00
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
2022-10-16 16:50:22 +08:00
return rsp.status
2023-09-21 22:46:26 +08:00
def query_sql(self, db: str, sql: str) -> List[Dict]:
2023-05-04 23:07:46 +08:00
"""执行 SQL如果数据量大注意分页以免 OOM
Args:
db (str): 要查询的数据库
sql (str): 要执行的 SQL
Returns:
2023-09-21 22:46:26 +08:00
List[Dict]: 查询结果
2023-05-04 23:07:46 +08:00
"""
2022-10-16 21:47:13 +08:00
result = []
2023-02-25 21:58:00 +08:00
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)
2023-03-01 19:38:18 +08:00
rows = json_format.MessageToDict(rsp.rows).get("rows", [])
2023-02-25 21:58:00 +08:00
for r in rows:
2022-10-16 21:47:13 +08:00
row = {}
2023-02-25 21:58:00 +08:00
for f in r["fields"]:
c = base64.b64decode(f.get("content", ""))
row[f["column"]] = self._SQL_TYPES[f["type"]](c)
2022-10-16 21:47:13 +08:00
result.append(row)
return result
2022-10-16 16:50:22 +08:00
2023-05-02 09:34:59 +08:00
def accept_new_friend(self, v3: str, v4: str, scene: int = 30) -> int:
"""通过好友申请
2023-05-04 23:07:46 +08:00
2023-05-02 09:34:59 +08:00
Args:
v3 (str): 加密用户名 (好友申请消息里 v3 开头的字符串)
v4 (str): Ticket (好友申请消息里 v4 开头的字符串)
scene: 申请方式 (好友申请消息里的 scene); 为了兼容旧接口默认为扫码添加 (30)
Returns:
int: 1 为成功其他失败
"""
2023-02-25 21:58:00 +08:00
req = wcf_pb2.Request()
req.func = wcf_pb2.FUNC_ACCEPT_FRIEND # FUNC_ACCEPT_FRIEND
req.v.v3 = v3
req.v.v4 = v4
2023-05-02 09:34:59 +08:00
req.v.scene = scene
2023-02-25 21:58:00 +08:00
rsp = self._send_request(req)
2022-10-16 16:50:22 +08:00
return rsp.status
2023-01-17 23:01:53 +08:00
2023-09-21 22:46:26 +08:00
def get_friends(self) -> List[Dict]:
2023-01-17 23:01:53 +08:00
"""获取好友列表"""
not_friends = {
"fmessage": "朋友推荐消息",
"medianote": "语音记事本",
"floatbottle": "漂流瓶",
"filehelper": "文件传输助手",
"newsapp": "新闻",
}
friends = []
2023-02-25 21:58:00 +08:00
for cnt in self.get_contacts():
2023-09-21 20:53:01 +08:00
if (cnt["wxid"].endswith("@chatroom") or # 群聊
2025-03-10 23:03:53 +08:00
cnt["wxid"].startswith("gh_") or # 公众号
cnt["wxid"] in not_friends.keys() # 其他杂号
):
2023-01-17 23:01:53 +08:00
continue
2023-02-25 21:58:00 +08:00
friends.append(cnt)
2023-01-17 23:01:53 +08:00
return friends
2023-02-28 20:14:22 +08:00
2023-06-26 10:30:08 +08:00
def receive_transfer(self, wxid: str, transferid: str, transactionid: str) -> int:
2023-04-19 23:30:04 +08:00
"""接收转账
2023-05-04 23:07:46 +08:00
2023-04-19 23:30:04 +08:00
Args:
wxid (str): 转账消息里的发送人 wxid
transferid (str): 转账消息里的 transferid
2023-06-26 10:30:08 +08:00
transactionid (str): 转账消息里的 transactionid
2023-04-19 23:30:04 +08:00
Returns:
int: 1 为成功其他失败
"""
2023-04-18 23:27:58 +08:00
req = wcf_pb2.Request()
req.func = wcf_pb2.FUNC_RECV_TRANSFER # FUNC_RECV_TRANSFER
req.tf.wxid = wxid
2023-06-26 10:30:08 +08:00
req.tf.tfid = transferid
req.tf.taid = transactionid
2023-04-18 23:27:58 +08:00
rsp = self._send_request(req)
return rsp.status
2023-07-16 19:42:45 +08:00
def refresh_pyq(self, id: int = 0) -> int:
2023-07-16 15:51:03 +08:00
"""刷新朋友圈
Args:
2023-07-16 19:42:45 +08:00
id (int): 开始 id0 为最新页
2023-07-16 15:51:03 +08:00
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
2023-11-26 23:12:52 +08:00
def download_attach(self, id: int, thumb: str, extra: str) -> int:
2023-12-03 16:45:48 +08:00
"""下载附件(图片、视频、文件)。这方法别直接调用,下载图片使用 `download_image`。
2023-11-21 21:05:29 +08:00
Args:
id (int): 消息中 id
thumb (str): 消息中的 thumb
extra (str): 消息中的 extra
Returns:
2023-11-26 23:12:52 +08:00
int: 0 为成功, 其他失败
2023-11-21 21:05:29 +08:00
"""
req = wcf_pb2.Request()
req.func = wcf_pb2.FUNC_DOWNLOAD_ATTACH # FUNC_DOWNLOAD_ATTACH
req.att.id = id
req.att.thumb = thumb
req.att.extra = extra
rsp = self._send_request(req)
2023-11-26 23:12:52 +08:00
return rsp.status
2023-11-21 21:05:29 +08:00
2023-11-26 17:50:31 +08:00
def get_info_by_wxid(self, wxid: str) -> dict:
"""通过 wxid 查询微信号昵称等信息
Args:
wxid (str): 联系人 wxid
Returns:
dict: {wxid, code, name, gender}
"""
raise Exception("Not implemented, yet")
2023-11-26 17:50:31 +08:00
req = wcf_pb2.Request()
req.func = wcf_pb2.FUNC_GET_CONTACT_INFO # FUNC_GET_CONTACT_INFO
req.str = wxid
rsp = self._send_request(req)
contacts = json_format.MessageToDict(rsp.contacts).get("contacts", [])
contact = {}
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}
return contact
2023-11-27 20:04:41 +08:00
def revoke_msg(self, id: int = 0) -> int:
"""撤回消息
Args:
id (int): 待撤回消息的 id
Returns:
int: 1 为成功其他失败
"""
2025-03-08 11:57:08 +08:00
raise Exception("Not implemented, yet")
2023-11-27 20:04:41 +08:00
req = wcf_pb2.Request()
req.func = wcf_pb2.FUNC_REVOKE_MSG # FUNC_REVOKE_MSG
req.ui64 = id
rsp = self._send_request(req)
return rsp.status
2023-11-26 23:12:52 +08:00
def decrypt_image(self, src: str, dir: str) -> str:
2023-12-03 16:45:48 +08:00
"""解密图片。这方法别直接调用,下载图片使用 `download_image`。
2023-05-04 23:07:46 +08:00
2023-04-12 17:17:32 +08:00
Args:
src (str): 加密的图片路径
2023-11-26 23:12:52 +08:00
dir (str): 保存图片的目录
2023-04-12 17:17:32 +08:00
Returns:
2023-11-26 23:12:52 +08:00
str: 解密图片的保存路径
2023-04-12 17:17:32 +08:00
"""
req = wcf_pb2.Request()
req.func = wcf_pb2.FUNC_DECRYPT_IMAGE # FUNC_DECRYPT_IMAGE
req.dec.src = src
2023-11-26 23:12:52 +08:00
req.dec.dst = dir
2023-04-12 17:17:32 +08:00
rsp = self._send_request(req)
2023-11-26 23:12:52 +08:00
return rsp.str
2023-12-06 23:58:33 +08:00
def get_ocr_result(self, extra: str, timeout: int = 2) -> str:
"""获取 OCR 结果。鸡肋,需要图片能自动下载;通过下载接口下载的图片无法识别。
Args:
extra (str): 待识别的图片路径消息里的 extra
Returns:
str: OCR 结果
"""
def _inner(extra):
req = wcf_pb2.Request()
req.func = wcf_pb2.FUNC_EXEC_OCR # FUNC_EXEC_OCR
req.str = extra
rsp = self._send_request(req)
ocr = json_format.MessageToDict(rsp.ocr)
return ocr.get("status", 0), ocr.get("result", "")
cnt = 0
while True:
status, result = _inner(extra)
if status == 0:
break
cnt += 1
if cnt > timeout:
break
sleep(1)
if status != 0:
self.LOG.error(f"OCR failed, status: {status}")
return result
2023-11-26 23:12:52 +08:00
def download_image(self, id: int, extra: str, dir: str, timeout: int = 30) -> str:
"""下载图片
Args:
id (int): 消息中 id
extra (str): 消息中的 extra
2023-12-03 16:45:48 +08:00
dir (str): 存放图片的目录目录不存在会出错
2023-11-26 23:12:52 +08:00
timeout (int): 超时时间
Returns:
str: 成功返回存储路径空字符串为失败原因见日志
"""
sleep(1) # 强制等待 1 秒让数据入库,避免那帮人总是嗷嗷叫超时
if (not os.path.exists(extra)) and (self.download_attach(id, "", extra) != 0):
2023-11-26 23:12:52 +08:00
self.LOG.error(f"下载失败")
return ""
cnt = 0
2023-12-07 00:06:34 +08:00
while cnt < timeout:
2023-11-26 23:12:52 +08:00
path = self.decrypt_image(extra, dir)
if path:
return path
2023-12-07 00:06:34 +08:00
sleep(1)
2023-11-26 23:12:52 +08:00
cnt += 1
self.LOG.error(f"下载超时")
return ""
2023-06-30 12:01:36 +08:00
2025-03-10 23:03:53 +08:00
def download_video(self, id: int, thumb: str, dir: str, timeout: int = 30) -> str:
"""下载视频
Args:
id (int): 消息中 id
thumb (str): 消息中的 thumb即视频的封面图
dir (str): 存放视频的目录目录不存在会出错
timeout (int): 超时时间
Returns:
str: 成功返回存储路径空字符串为失败原因见日志
"""
sleep(1) # 强制等待 1 秒让数据入库,避免那帮人总是嗷嗷叫超时
2025-03-10 23:03:53 +08:00
base, _ = os.path.splitext(thumb)
file_path = base + ".mp4"
file_name = os.path.basename(file_path)
target_path = os.path.join(dir, file_name)
if (not os.path.exists(target_path)) and (not os.path.exists(file_path)) and (self.download_attach(id, thumb, "") != 0):
self.LOG.error(f"下载失败")
return ""
cnt = 0
while cnt < timeout:
if os.path.exists(file_path):
os.makedirs(dir, exist_ok=True)
shutil.move(file_path, target_path)
return target_path
sleep(1)
cnt += 1
self.LOG.error(f"下载超时")
return ""
2023-06-30 12:01:36 +08:00
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
2023-09-21 20:53:01 +08:00
2023-12-06 22:05:27 +08:00
def invite_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_INV_ROOM_MEMBERS # FUNC_INV_ROOM_MEMBERS
req.m.roomid = roomid
req.m.wxids = wxids.replace(" ", "")
rsp = self._send_request(req)
return rsp.status
2023-09-21 22:46:26 +08:00
def get_chatroom_members(self, roomid: str) -> Dict:
2023-09-21 20:53:01 +08:00
"""获取群成员
Args:
roomid (str): 群的 id
Returns:
2023-09-21 22:46:26 +08:00
Dict: 群成员列表: {wxid1: 昵称1, wxid2: 昵称2, ...}
2023-09-21 20:53:01 +08:00
"""
2023-09-21 22:46:26 +08:00
members = {}
2023-09-21 20:53:01 +08:00
contacts = self.query_sql("MicroMsg.db", "SELECT UserName, NickName FROM Contact;")
contacts = {contact["UserName"]: contact["NickName"]for contact in contacts}
2023-09-21 22:46:26 +08:00
crs = self.query_sql("MicroMsg.db", f"SELECT RoomData FROM ChatRoom WHERE ChatRoomName = '{roomid}';")
2023-09-21 20:53:01 +08:00
if not crs:
2023-09-21 22:46:26 +08:00
return members
2023-09-21 20:53:01 +08:00
bs = crs[0].get("RoomData")
if not bs:
2023-09-21 22:46:26 +08:00
return members
2023-09-21 20:53:01 +08:00
crd = RoomData()
crd.ParseFromString(bs)
if not bs:
2023-09-21 22:46:26 +08:00
return members
2023-09-21 20:53:01 +08:00
for member in crd.members:
2023-09-21 22:46:26 +08:00
members[member.wxid] = member.name if member.name else contacts.get(member.wxid, "")
2023-09-21 20:53:01 +08:00
2023-09-21 22:46:26 +08:00
return members
2023-09-21 22:53:16 +08:00
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 ""