diff --git a/docs/messages.rst b/docs/messages.rst index db0b8b4..072955f 100644 --- a/docs/messages.rst +++ b/docs/messages.rst @@ -45,17 +45,13 @@ # 系统 SYSTEM = 'System' -.. attribute:: Message.create_time - - 消息的发送时间戳 - .. attribute:: Message.bot 接收此消息的 :class:`机器人对象 ` .. attribute:: Message.id - 消息的唯一ID + 消息的唯一 ID (通常为大于 0 的 64 位整型) 内容数据 @@ -117,6 +113,22 @@ 当消息来自群聊,且被 @ 时,为 True +时间相关 +^^^^^^^^^^^^^^^^^ + +.. attribute:: Message.create_time + + 消息的发送时间 + +.. attribute:: Message.receive_time + + 消息的接收时间 + +.. attribute:: Message.latency + + 消息的延迟秒数 (发送时间和接收时间的差值) + + 其他属性 ^^^^^^^^^^^^^^^^ diff --git a/wxpy/__init__.py b/wxpy/__init__.py index 2079540..a56b0ec 100644 --- a/wxpy/__init__.py +++ b/wxpy/__init__.py @@ -42,19 +42,20 @@ def reply_my_friend(msg): """ + import sys from .api.bot import Bot from .api.chats import Chat, Chats, Friend, Group, Groups, MP, Member, User -from .api.chats import FEMALE, MALE -from .api.messages import ATTACHMENT, CARD, FRIENDS, MAP, NOTE, PICTURE, RECORDING, SHARING, SYSTEM, TEXT, VIDEO -from .api.messages import Message, Messages +from .api.consts import ATTACHMENT, CARD, FRIENDS, MAP, NOTE, PICTURE, RECORDING, SHARING, SYSTEM, TEXT, VIDEO +from .api.consts import FEMALE, MALE +from .api.messages import Message, Messages, SentMessage from .exceptions import ResponseError from .ext import Tuling, WeChatLoggingHandler, XiaoI, get_wechat_logger, sync_message_in_groups -from .utils import dont_raise_response_error, embed, ensure_one, mutual_friends +from .utils import BaseRequest, dont_raise_response_error, embed, ensure_one, mutual_friends __title__ = 'wxpy' -__version__ = '0.3.6' +__version__ = '0.3.7' __author__ = 'Youfou' __license__ = 'MIT' __copyright__ = '2017, Youfou' diff --git a/wxpy/api/bot.py b/wxpy/api/bot.py index bd1ef76..15831f8 100644 --- a/wxpy/api/bot.py +++ b/wxpy/api/bot.py @@ -3,16 +3,16 @@ import logging import os.path import queue -import re import tempfile from pprint import pformat from threading import Thread import itchat -from wxpy.api.chats import Chat, Chats, Friend, Group, MP, User -from wxpy.api.messages import Message, MessageConfig, Messages, Registered, SYSTEM -from wxpy.utils import enhance_connection, ensure_list, get_user_name, handle_response, wrap_user_name +from ..api.chats import Chat, Chats, Friend, Group, MP, User +from ..api.consts import SYSTEM +from ..api.messages import Message, MessageConfig, Messages, Registered +from ..utils import enhance_connection, ensure_list, get_user_name, handle_response, start_new_thread, wrap_user_name logger = logging.getLogger(__name__) @@ -61,7 +61,7 @@ def __init__( loginCallback=login_callback, exitCallback=logout_callback ) - self.self = Friend(self.core.loginInfo['User'], self) + self.self = User(self.core.loginInfo['User'], self) self.file_helper = Chat(wrap_user_name('filehelper'), self) self.messages = Messages() @@ -235,26 +235,38 @@ def add_friend(self, user, verify_content=''): """ 添加用户为好友 - :param user: 用户对象、微信ID,或 user_name + :param user: 用户对象,或 user_name,或用户的微信ID :param verify_content: 验证说明信息 """ logger.info('{}: adding {} (verify_content: {})'.format(self, user, verify_content)) - user_name = get_user_name(user) - if re.match(r'^@[\da-f]{32,}$', user_name): - status = 2 - else: - status = 1 - return self.core.add_friend( userName=user_name, - status=status, + status=2, verifyContent=verify_content, autoUpdate=True ) + @handle_response() + def add_mp(self, user): + + """ + 添加/关注 公众号 + + :param user: 公众号对象,或 user_name, 或公众号的微信ID + """ + + logger.info('{}: adding {}'.format(self, user)) + user_name = get_user_name(user) + + return self.core.add_friend( + userName=user_name, + status=1, + autoUpdate=True + ) + def accept_friend(self, user, verify_content=''): """ 接受用户为好友 @@ -366,7 +378,7 @@ def process(): logger.exception('\nAn error occurred in {}.'.format(config.func)) if config.run_async: - Thread(target=process, daemon=True).start() + start_new_thread(process, use_caller_name=True) else: process() @@ -420,8 +432,7 @@ def start(self): elif self.is_listening: logger.warning('{} is already running, no need to start again.'.format(self)) else: - self.listening_thread = Thread(target=self._listen, daemon=True) - self.listening_thread.start() + self.listening_thread = start_new_thread(self._listen) def stop(self): """ diff --git a/wxpy/api/chats/__init__.py b/wxpy/api/chats/__init__.py index 72421f9..e252988 100644 --- a/wxpy/api/chats/__init__.py +++ b/wxpy/api/chats/__init__.py @@ -5,5 +5,4 @@ from .groups import Groups from .member import Member from .mp import MP -from .user import FEMALE, MALE from .user import User diff --git a/wxpy/api/chats/chat.py b/wxpy/api/chats/chat.py index 78e9042..7b10eea 100644 --- a/wxpy/api/chats/chat.py +++ b/wxpy/api/chats/chat.py @@ -1,15 +1,73 @@ -import json +import datetime import logging +import re import time +from functools import partial, wraps -import itchat.config -import itchat.returnvalues - +from wxpy.api.consts import ATTACHMENT, PICTURE, TEXT, VIDEO from wxpy.utils import handle_response logger = logging.getLogger(__name__) +def wrap_sender(msg_type): + """ + 包裹 send() 系列方法,完成发送过程,并返回 SentMessage 对象 + """ + + def decorator(func): + @wraps(func) + def wrapped(self, *args, **kwargs): + + # 用于初始化 SentMessage 的属性 + attrs = dict(type=msg_type, receiver=self) + attrs['create_time'] = datetime.datetime.now() + + # 被装饰函数返回: + # 1. 请求 itchat 原函数的参数字典 (或返回值字典) + # 2. SentMessage 属性字典 + kwargs_, attrs_ = func(self, *args, **kwargs) + + if msg_type: + # 原 itchat 函数的偏函数 + func_ = partial( + getattr(self.bot.core, func.__name__), + toUserName=self.user_name) + + @handle_response() + def do(): + return func_(**kwargs_) + + logger.info('sending {} to {}:\n{}'.format( + func.__name__[5:], self, attrs_['text'] or attrs_['path'])) + ret_ = do() + else: + # send_raw_msg 会直接返回结果 + ret_ = kwargs_ + + attrs['receive_time'] = datetime.datetime.now() + + attrs['id'] = ret_.get('MsgID') + try: + attrs['id'] = int(attrs['id']) + except (ValueError, TypeError): + pass + + attrs['local_id'] = ret_.get('LocalID') + # 合入被装饰函数提供的属性字典 + attrs.update(attrs_) + + from wxpy import SentMessage + sent = SentMessage(attributes=attrs, bot=self.bot) + self.bot.messages.append(sent) + + return sent + + return wrapped + + return decorator + + class Chat(object): """ 单个用户(:class:`User`)和群聊(:class:`Group`)的基础类 @@ -20,8 +78,33 @@ def __init__(self, raw, bot): self.raw = raw self.bot = bot - self.alias = self.raw.get('Alias') - self.uin = self.raw.get('Uin') + @property + def uin(self): + """ + 微信中的聊天对象ID,固定且唯一 + + .. note:: 因微信的隐私策略,该值并不一定总是可获取到 + """ + return self.raw.get('Uin') + + @property + def alias(self): + """ + 若用户进行过一次性的 "设置微信号" 操作,则该值为用户设置的"微信号",固定且唯一 + + .. note:: 因微信的隐私策略,该值并不一定总是可获取到 + """ + return self.raw.get('Alias') + + @property + def wxid(self): + """ + 聊天对象的微信ID (实际为 .alias 或 .uin) + + .. note:: 因微信的隐私策略,该值并不一定总是可获取到 + """ + + return self.alias or self.uin or None @property def nick_name(self): @@ -46,15 +129,6 @@ def name(self): if _name: return _name - @property - def wxid(self): - """ - | 微信号 - | 有可能获取不到 (手机客户端也可能获取不到) - """ - - return self.alias or self.uin or None - @property def user_name(self): """ @@ -66,8 +140,7 @@ def user_name(self): """ return self.raw.get('UserName') - @handle_response() - def send(self, content='Hello, wxpy!', media_id=None): + def send(self, content=None, media_id=None): """ 动态发送不同类型的消息,具体类型取决于 `msg` 的前缀。 @@ -77,62 +150,79 @@ def send(self, content='Hello, wxpy!', media_id=None): * 分别表示: 文件,图片,纯文本,视频 * **内容** 部分可为: 文件、图片、视频的路径,或纯文本的内容 :param media_id: 填写后可省略上传过程 + :rtype: :class:`wxpy.SentMessage` """ - logger.info('sending to {}:\n{}'.format(self, content)) - return self.bot.core.send(msg=str(content), toUserName=self.user_name, mediaId=media_id) - @handle_response() + method_map = dict(fil=self.send_file, img=self.send_image, vid=self.send_video) + + try: + send_type, content = re.match(r'@(\w{3})@(.*)', content).groups() + return method_map[send_type](path=content or None, media_id=media_id) + except (AttributeError, KeyError, TypeError): + return self.send_msg(msg=content) + + @wrap_sender(TEXT) + def send_msg(self, msg=None): + """ + 发送文本消息 + + :param msg: 文本内容 + :rtype: :class:`wxpy.SentMessage` + """ + + if not msg and msg is not 0: + msg = 'Hello from wxpy!' + else: + msg = str(msg) + + return dict(msg=msg), dict(text=msg) + + @wrap_sender(PICTURE) def send_image(self, path, media_id=None): """ 发送图片 :param path: 文件路径 :param media_id: 设置后可省略上传 + :rtype: :class:`wxpy.SentMessage` """ - logger.info('sending image to {}:\n{}'.format(self, path)) - return self.bot.core.send_image(fileDir=path, toUserName=self.user_name, mediaId=media_id) - @handle_response() + return dict(fileDir=path, mediaId=media_id), locals() + + @wrap_sender(ATTACHMENT) def send_file(self, path, media_id=None): """ 发送文件 :param path: 文件路径 :param media_id: 设置后可省略上传 + :rtype: :class:`wxpy.SentMessage` """ - logger.info('sending file to {}:\n{}'.format(self, path)) - return self.bot.core.send_file(fileDir=path, toUserName=self.user_name, mediaId=media_id) - @handle_response() + return dict(fileDir=path, mediaId=media_id), locals() + + @wrap_sender(VIDEO) def send_video(self, path=None, media_id=None): """ 发送视频 :param path: 文件路径 :param media_id: 设置后可省略上传 + :rtype: :class:`wxpy.SentMessage` """ - logger.info('sending video to {}:\n{}'.format(self, path)) - return self.bot.core.send_video(fileDir=path, toUserName=self.user_name, mediaId=media_id) - @handle_response() - def send_msg(self, msg): - """ - 发送文本消息 + return dict(fileDir=path, mediaId=media_id), locals() - :param msg: 文本内容 - """ - logger.info('sending msg to {}:\n{}'.format(self, msg)) - return self.bot.core.send_msg(msg=str(msg), toUserName=self.user_name) - - @handle_response() - def send_raw_msg(self, msg_type, content, uri=None, msg_ext=None): + @wrap_sender(None) + def send_raw_msg(self, raw_type, raw_content, uri=None, msg_ext=None): """ 以原始格式发送其他类型的消息。 - :param int msg_type: 原始的整数消息类型 - :param str content: 原始的消息内容 + :param int raw_type: 原始的整数消息类型 + :param str raw_content: 原始的消息内容 :param str uri: 请求路径,默认为 '/webwxsendmsg' :param dict msg_ext: 消息的扩展属性 (会被更新到 `Msg` 键中) + :rtype: :class:`wxpy.SentMessage` 例如,好友名片:: @@ -143,12 +233,15 @@ def reply_text(msg): msg.chat.send_raw_msg(msg.raw['MsgType'], msg.raw['Content']) """ - core = self.bot.core - url = core.loginInfo['url'] + (uri or '/webwxsendmsg') + logger.info('sending raw msg to {}'.format(self)) + + from wxpy.utils import BaseRequest + + req = BaseRequest(self.bot, uri=uri) msg = { - 'Type': msg_type, - 'Content': content, + 'Type': raw_type, + 'Content': raw_content, 'FromUserName': self.bot.self.user_name, 'ToUserName': self.user_name, 'LocalID': int(time.time() * 1e4), @@ -158,25 +251,16 @@ def reply_text(msg): if msg_ext: msg.update(msg_ext) - payload = { - 'BaseRequest': core.loginInfo['BaseRequest'], - 'Msg': msg, - 'Scene': 0, - } + req.data.update({'Msg': msg, 'Scene': 0}) - headers = { - 'ContentType': 'application/json; charset=UTF-8', - 'User-Agent': itchat.config.USER_AGENT + # noinspection PyUnresolvedReferences + return req.post(), { + 'raw_type': raw_type, + 'raw_content': raw_content, + 'uri': uri, + 'msg_ext': msg_ext, } - r = core.s.post( - url, headers=headers, - data=json.dumps(payload, ensure_ascii=False).encode('utf-8') - ) - - logger.info('sent raw msg to {}'.format(self)) - return itchat.returnvalues.ReturnValue(rawResponse=r) - @handle_response() def pin(self): """ diff --git a/wxpy/api/chats/chats.py b/wxpy/api/chats/chats.py index c5ee12c..41b6c0d 100644 --- a/wxpy/api/chats/chats.py +++ b/wxpy/api/chats/chats.py @@ -70,7 +70,8 @@ def stats_text(self, total=True, sex=True, top_provinces=10, top_cities=10): """ from .group import Group - from .user import FEMALE, MALE + from wxpy.api.consts import FEMALE + from wxpy.api.consts import MALE from wxpy.api.bot import Bot def top_n_text(attr, n): diff --git a/wxpy/api/chats/group.py b/wxpy/api/chats/group.py index f598858..d4da7f0 100644 --- a/wxpy/api/chats/group.py +++ b/wxpy/api/chats/group.py @@ -83,9 +83,10 @@ def self(self): """ 机器人自身 (作为群成员) """ - for member in self: + for member in self.members: if member == self.bot.self: return member + return Member(self.bot.core.loginInfo['User'], self) def update_group(self, members_details=False): """ diff --git a/wxpy/api/chats/user.py b/wxpy/api/chats/user.py index bea3931..e3f2478 100644 --- a/wxpy/api/chats/user.py +++ b/wxpy/api/chats/user.py @@ -1,8 +1,5 @@ from .chat import Chat -MALE = 1 -FEMALE = 2 - class User(Chat): """ @@ -52,10 +49,15 @@ def is_friend(self): """ 判断当前用户是否为好友关系 - :return: 若为好友关系则为 True,否则为 False + :return: 若为好友关系,返回对应的好友,否则返回 False """ if self.bot: - return self in self.bot.friends() + try: + friends = self.bot.friends() + index = friends.index(self) + return friends[index] + except ValueError: + return False def add(self, verify_content=''): """ diff --git a/wxpy/api/consts.py b/wxpy/api/consts.py new file mode 100644 index 0000000..291f715 --- /dev/null +++ b/wxpy/api/consts.py @@ -0,0 +1,27 @@ +# 文本 +TEXT = 'Text' +# 位置 +MAP = 'Map' +# 名片 +CARD = 'Card' +# 提示 +NOTE = 'Note' +# 分享 +SHARING = 'Sharing' +# 图片 +PICTURE = 'Picture' +# 语音 +RECORDING = 'Recording' +# 文件 +ATTACHMENT = 'Attachment' +# 视频 +VIDEO = 'Video' +# 好友请求 +FRIENDS = 'Friends' +# 系统 +SYSTEM = 'System' + +# 男性 +MALE = 1 +# 女性 +FEMALE = 2 diff --git a/wxpy/api/messages/__init__.py b/wxpy/api/messages/__init__.py index b56723a..7cc5b75 100644 --- a/wxpy/api/messages/__init__.py +++ b/wxpy/api/messages/__init__.py @@ -1,5 +1,5 @@ -from .message import ATTACHMENT, CARD, FRIENDS, MAP, NOTE, PICTURE, RECORDING, SHARING, SYSTEM, TEXT, VIDEO from .message import Message from .message_config import MessageConfig -from .registered import Registered from .messages import Messages +from .registered import Registered +from .sent_message import SentMessage diff --git a/wxpy/api/messages/message.py b/wxpy/api/messages/message.py index 71014ec..833bd4c 100644 --- a/wxpy/api/messages/message.py +++ b/wxpy/api/messages/message.py @@ -8,99 +8,27 @@ from wxpy.api.chats import Chat, Group, Member, User from wxpy.utils import wrap_user_name +from ..consts import ATTACHMENT, CARD, FRIENDS, MAP, PICTURE, RECORDING, SHARING, TEXT, VIDEO logger = logging.getLogger(__name__) -# 文本 -TEXT = 'Text' -# 位置 -MAP = 'Map' -# 名片 -CARD = 'Card' -# 提示 -NOTE = 'Note' -# 分享 -SHARING = 'Sharing' -# 图片 -PICTURE = 'Picture' -# 语音 -RECORDING = 'Recording' -# 文件 -ATTACHMENT = 'Attachment' -# 视频 -VIDEO = 'Video' -# 好友请求 -FRIENDS = 'Friends' -# 系统 -SYSTEM = 'System' - class Message(object): """ - 单条消息对象 + 单条消息对象,包括: + + * 来自好友、群聊、好友请求等聊天对象的消息 + * 使用机器人账号在手机微信中发送的消息 + + | 但 **不包括** 代码中通过 .send/reply() 系列方法发出的消息 + | 此类消息请参见 :class:`SentMessage` """ def __init__(self, raw, bot): self.raw = raw - self.bot = weakref.proxy(bot) - self.type = self.raw.get('Type') - - self.is_at = self.raw.get('IsAt') or self.raw.get('isAt') - - self.file_name = self.raw.get('FileName') - self.file_size = self.raw.get('FileSize') - self.media_id = self.raw.get('MediaId') - - self.img_height = self.raw.get('ImgHeight') - self.img_width = self.raw.get('ImgWidth') - - self.play_length = self.raw.get('PlayLength') - self.voice_length = self.raw.get('VoiceLength') - - self.url = self.raw.get('Url') - if isinstance(self.url, str): - self.url = html.unescape(self.url) - - self.id = self.raw.get('NewMsgId') - - self.text = None - self.get_file = None - self.create_time = None - self.location = None - self.card = None - - text = self.raw.get('Text') - if callable(text): - self.get_file = text - else: - self.text = text - - # noinspection PyBroadException - try: - self.create_time = datetime.fromtimestamp(self.raw.get('CreateTime')) - except: - pass - if self.type == MAP: - try: - self.location = ETree.fromstring(self.raw['OriContent']).find('location').attrib - try: - self.location['x'] = float(self.location['x']) - self.location['y'] = float(self.location['y']) - self.location['scale'] = int(self.location['scale']) - self.location['maptype'] = int(self.location['maptype']) - except (KeyError, ValueError): - pass - self.text = self.location.get('label') - except (TypeError, KeyError, ValueError, ETree.ParseError): - pass - elif self.type in (CARD, FRIENDS): - self.card = User(self.raw.get('RecommendInfo'), self.bot) - if self.type is CARD: - self.text = self.card.name - else: - self.text = self.card.raw.get('Content') + self._receive_time = datetime.now() # 将 msg.chat.send* 方法绑定到 msg.reply*,例如 msg.chat.send_img => msg.reply_img for method in '', '_image', '_file', '_video', '_msg', '_raw_msg': @@ -124,6 +52,212 @@ def __repr__(self): return ret.format(self=self, text=text) + # basic + + @property + def type(self): + """ + 消息的类型,目前可为以下值:: + + # 文本 + TEXT = 'Text' + # 位置 + MAP = 'Map' + # 名片 + CARD = 'Card' + # 提示 + NOTE = 'Note' + # 分享 + SHARING = 'Sharing' + # 图片 + PICTURE = 'Picture' + # 语音 + RECORDING = 'Recording' + # 文件 + ATTACHMENT = 'Attachment' + # 视频 + VIDEO = 'Video' + # 好友请求 + FRIENDS = 'Friends' + # 系统 + SYSTEM = 'System' + + :return: str + """ + return self.raw.get('Type') + + @property + def id(self): + """ + 消息的唯一 ID + """ + return self.raw.get('NewMsgId') + + # content + @property + def text(self): + """ + 消息的文本内容 + """ + _type = self.type + _card = self.card + + if _type is MAP: + location = self.location + if location: + return location.get('label') + elif _card: + if _type is CARD: + return _card.name + elif _type is FRIENDS: + return _card.raw.get('Content') + + ret = self.raw.get('Text') + if isinstance(ret, str): + return ret + + def get_file(self, save_path=None): + """ + 下载图片、视频、语音、附件消息中的文件内容。 + + :param save_path: 文件的保存路径。若为 None,将直接返回字节数据 + """ + + _text = self.raw.get('Text') + if callable(_text) and self.type in (PICTURE, RECORDING, ATTACHMENT, VIDEO): + return _text(save_path) + else: + raise ValueError('download method not found, or invalid message type') + + @property + def file_name(self): + """ + 消息中文件的文件名 + """ + return self.raw.get('FileName') + + @property + def file_size(self): + """ + 消息中文件的体积大小 + """ + return self.raw.get('FileSize') + + @property + def media_id(self): + """ + 消息中的文件 media_id,可用于转发消息 + """ + return self.raw.get('MediaId') + + # group + + @property + def is_at(self): + """ + 当消息来自群聊,且被 @ 时,为 True + """ + return self.raw.get('IsAt') or self.raw.get('isAt') + + # misc + + @property + def img_height(self): + """ + 图片高度 + """ + return self.raw.get('ImgHeight') + + @property + def img_width(self): + """ + 图片宽度 + """ + return self.raw.get('ImgWidth') + + @property + def play_length(self): + """ + 视频长度 + """ + return self.raw.get('PlayLength') + + @property + def voice_length(self): + """ + 语音长度 + """ + return self.raw.get('VoiceLength') + + @property + def url(self): + """ + 分享消息中的网页 URL + """ + ret = self.raw.get('Url') + if isinstance(ret, str): + ret = html.unescape(self.url) + + return ret + + @property + def card(self): + """ + * 好友请求中的请求用户 + * 名片消息中的推荐用户 + """ + if self.type in (CARD, FRIENDS): + return User(self.raw.get('RecommendInfo'), self.bot) + + # time + + @property + def create_time(self): + """ + 服务端发送时间 + """ + # noinspection PyBroadException + try: + return datetime.fromtimestamp(self.raw.get('CreateTime')) + except: + pass + + @property + def receive_time(self): + """ + 本地接收时间 + """ + return self._receive_time + + @property + def latency(self): + """ + 消息的延迟秒数 (发送时间和接收时间的差值) + """ + create_time = self.create_time + if create_time: + return (self.receive_time - create_time).total_seconds() + + @property + def location(self): + """ + 消息中的地理位置信息 + """ + try: + ret = ETree.fromstring(self.raw['OriContent']).find('location').attrib + try: + ret['x'] = float(ret['x']) + ret['y'] = float(ret['y']) + ret['scale'] = int(ret['scale']) + ret['maptype'] = int(ret['maptype']) + except (KeyError, ValueError): + pass + return ret + except (TypeError, KeyError, ValueError, ETree.ParseError): + pass + + # chats + @property def chat(self): """ @@ -338,8 +472,8 @@ def raise_properly(text): return wrapped_send( send_type='raw_msg', - msg_type=self.raw['MsgType'], - content=content, + raw_type=self.raw['MsgType'], + raw_content=content, uri='/webwxsendappmsg?fun=async&f=json' ) @@ -350,8 +484,8 @@ def raise_properly(text): else: return wrapped_send( send_type='raw_msg', - msg_type=self.raw['MsgType'], - content=self.raw['Content'], + raw_type=self.raw['MsgType'], + raw_content=self.raw['Content'], uri='/webwxsendmsg' ) diff --git a/wxpy/api/messages/registered.py b/wxpy/api/messages/registered.py index 9923b21..795a585 100644 --- a/wxpy/api/messages/registered.py +++ b/wxpy/api/messages/registered.py @@ -1,6 +1,6 @@ import weakref -from .message import SYSTEM +from wxpy.api.consts import SYSTEM class Registered(list): diff --git a/wxpy/api/messages/sent_message.py b/wxpy/api/messages/sent_message.py new file mode 100644 index 0000000..97892b9 --- /dev/null +++ b/wxpy/api/messages/sent_message.py @@ -0,0 +1,91 @@ +import logging + +from . import Message + +logger = logging.getLogger(__name__) + + +class SentMessage(object): + """ + 代码中通过 .send/reply() 系列方法发出的消息 + """ + + def __init__(self, attributes, bot): + self.bot = bot + + # 消息的类型 (仅可为 'Text', 'Picture', 'Video', 'Attachment') + self.type = None + + # 消息的服务端 ID + self.id = None + + # 消息的本地 ID (撤回时需要用到) + self.local_id = None + + # 消息的文本内容 + self.text = None + + # 消息附件的本地路径 + self.path = None + + # 消息的附件 media_id + self.media_id = None + + # 本地发送时间 + self.create_time = None + + # 接收服务端响应时间 + self.receive_time = None + + # 消息的发送者 (始终为机器人自身) + self.sender = self.bot.self + self.receiver = None + + # send_raw_msg 的各属性 + self.raw_type = None + self.raw_content = None + self.uri = None + self.msg_ext = None + + for k, v in attributes.items(): + setattr(self, k, v) + + def __hash__(self): + return hash((SentMessage, self.id)) + + def __repr__(self): + # noinspection PyTypeChecker,PyCallByClass + return Message.__repr__(self) + + @property + def latency(self): + """ + 消息的延迟秒数 (发送时间和响应时间的差值) + """ + if self.create_time and self.receive_time: + return (self.receive_time - self.create_time).total_seconds() + + @property + def chat(self): + """ + 消息所在的聊天会话 (始终为消息的接受者) + """ + return self.receiver + + def recall(self): + """ + 撤回本条消息 (应为 2 分钟内发出的消息) + """ + + logger.info('recalling msg:\n{}'.format(self)) + + from wxpy.utils import BaseRequest + req = BaseRequest(self.bot, '/webwxrevokemsg') + req.data.update({ + "ClientMsgId": self.local_id, + "SvrMsgId": str(self.id), + "ToUserName": self.receiver.user_name, + }) + + # noinspection PyUnresolvedReferences + return req.post() diff --git a/wxpy/ext/sync_message_in_groups.py b/wxpy/ext/sync_message_in_groups.py index ed7ce53..1c2b0cd 100644 --- a/wxpy/ext/sync_message_in_groups.py +++ b/wxpy/ext/sync_message_in_groups.py @@ -2,7 +2,8 @@ # coding: utf-8 from binascii import crc32 -from threading import Thread + +from wxpy.utils import start_new_thread emojis = \ '😀😁😂🤣😃😄😅😆😉😊😋😎😍😘😗😙😚🙂🤗🤔😐😑😶🙄😏😣😥😮🤐😯' \ @@ -101,6 +102,6 @@ def process(): prefix = forward_prefix(msg.member) if run_async: - Thread(target=process, daemon=True).start() + start_new_thread(process, use_caller_name=True) else: process() diff --git a/wxpy/ext/xiaoi.py b/wxpy/ext/xiaoi.py index de2faae..1a2a6b0 100644 --- a/wxpy/ext/xiaoi.py +++ b/wxpy/ext/xiaoi.py @@ -5,6 +5,7 @@ import collections import hashlib import logging +import re import requests @@ -88,8 +89,14 @@ def _make_http_header_xauth(self): } return ret - - def do_reply(self, msg): + + def is_last_member(self, msg): + if msg.member == self.last_member.get(msg.chat): + return True + else: + self.last_member[msg.chat] = msg.member + + def do_reply(self, msg, at_member=True): """ 回复消息,并返回答复文本 @@ -97,11 +104,11 @@ def do_reply(self, msg): :return: 答复文本 """ - ret = self.reply_text(msg) + ret = self.reply_text(msg, at_member) msg.reply(ret) return ret - def reply_text(self, msg): + def reply_text(self, msg, at_member=True): """ 仅返回答复文本 @@ -109,8 +116,8 @@ def reply_text(self, msg): :return: 答复文本 """ - error_response = ( - "主人还没给我设置这类话题的回复", + error_patterns = ( + re.compile(r'^主人还没给我设置这类话题的回复*'), ) if isinstance(msg, Message): @@ -128,10 +135,17 @@ def reply_text(self, msg): } resp = self.session.post(self.url, data=params) - text = resp.text - - for err in error_response: - if err in text: - return next_topic() + if resp.ok: + text = resp.text + + for pattern in error_patterns: + if re.match(pattern, text): + raise ValueError('No sense reply {}'.format(text)) + + if at_member: + if len(msg.chat) > 2 and msg.member.name and not self.is_last_member(msg): + return "@{0} {1}".format(msg.member.name, text) + return text + else: + raise ValueError('Error Reply!!!') - return text diff --git a/wxpy/utils/__init__.py b/wxpy/utils/__init__.py index 91449c8..585d39d 100644 --- a/wxpy/utils/__init__.py +++ b/wxpy/utils/__init__.py @@ -1,4 +1,5 @@ +from .base_request import BaseRequest from .console import embed, shell_entry -from .misc import enhance_connection, ensure_list, get_receiver, get_user_name, \ - handle_response, match_attributes, match_name, match_text, smart_map, wrap_user_name +from .misc import enhance_connection, ensure_list, get_receiver, get_text_without_at_bot, get_user_name, \ + handle_response, match_attributes, match_name, match_text, smart_map, start_new_thread, wrap_user_name from .tools import dont_raise_response_error, ensure_one, mutual_friends diff --git a/wxpy/utils/base_request.py b/wxpy/utils/base_request.py new file mode 100644 index 0000000..dadf1e2 --- /dev/null +++ b/wxpy/utils/base_request.py @@ -0,0 +1,60 @@ +import functools +import json + +import itchat.config +import itchat.returnvalues + +from .misc import handle_response + + +class BaseRequest(object): + def __init__(self, bot, uri='/webwxsendmsg'): + """ + 基本的 Web 微信请求模板,可用于修改后发送请求 + + 可修改属性包括: + + * url (会通过 url 参数自动拼接好) + * data (默认仅包含 BaseRequest 部分) + * headers + + :param bot: 所使用的机器人对象 + :param uri: API 路径,将与基础 URL 进行拼接 + """ + self.bot = bot + self.url = self.bot.core.loginInfo['url'] + uri + self.data = {'BaseRequest': self.bot.core.loginInfo['BaseRequest']} + self.headers = { + 'ContentType': 'application/json; charset=UTF-8', + 'User-Agent': itchat.config.USER_AGENT + } + + for method in 'get', 'post', 'put', 'delete': + setattr(self, method, functools.partial( + self.request, method=method.upper() + )) + + def request(self, method, to_class=None): + """ + (在完成修改后) 发送请求 + + :param method: 请求方法: 'GET', 'POST','PUT', 'DELETE' 等 + :param to_class: 使用 `@handle_response(to_class)` 把结果转化为相应的类 + """ + + if self.data: + self.data = json.dumps(self.data, ensure_ascii=False).encode('utf-8') + else: + self.data = None + + @handle_response(to_class) + def do(): + return itchat.returnvalues.ReturnValue( + rawResponse=self.bot.core.s.request( + method=method, + url=self.url, + data=self.data, + headers=self.headers + )) + + return do() diff --git a/wxpy/utils/console.py b/wxpy/utils/console.py index b918d25..a259660 100644 --- a/wxpy/utils/console.py +++ b/wxpy/utils/console.py @@ -37,8 +37,8 @@ def _python(local, banner): def embed(local=None, banner='', shell=None): """ - | 进入交互式的 Python 命令行界面,并堵塞当前线程。 - | 支持使用 bpython,ipython,以及原生 python。 + | 进入交互式的 Python 命令行界面,并堵塞当前线程 + | 支持使用 ipython, bpython 以及原生 python :param str shell: | 指定命令行类型,可设为 'ipython','bpython','python',或它们的首字母; diff --git a/wxpy/utils/misc.py b/wxpy/utils/misc.py index 3b1579e..584c965 100644 --- a/wxpy/utils/misc.py +++ b/wxpy/utils/misc.py @@ -1,5 +1,7 @@ import inspect +import logging import re +import threading import weakref from functools import wraps @@ -42,34 +44,34 @@ def wrapped(*args, **kwargs): if ret is None: return - if args: - self = args[0] - else: - self = inspect.currentframe().f_back.f_locals.get('self') - - from wxpy.api.bot import Bot - if isinstance(self, Bot): - bot = weakref.proxy(self) - else: - bot = getattr(self, 'bot', None) - if not bot: - raise ValueError('bot not found:m\nmethod: {}\nself: {}\nbot: {}'.format( - func, self, bot - )) - smart_map(check_response_body, ret) if to_class: + if args: + self = args[0] + else: + self = inspect.currentframe().f_back.f_locals.get('self') + + from wxpy.api.bot import Bot + if isinstance(self, Bot): + bot = weakref.proxy(self) + else: + bot = getattr(self, 'bot', None) + if not bot: + raise ValueError('bot not found:m\nmethod: {}\nself: {}\nbot: {}'.format( + func, self, bot + )) + ret = smart_map(to_class, ret, bot) - if isinstance(ret, list): - from wxpy.api.chats import Group - if to_class is Group: - from wxpy.api.chats import Groups - ret = Groups(ret) - elif to_class: - from wxpy.api.chats import Chats - ret = Chats(ret, bot) + if isinstance(ret, list): + from wxpy.api.chats import Group + if to_class is Group: + from wxpy.api.chats import Groups + ret = Groups(ret) + else: + from wxpy.api.chats import Chats + ret = Chats(ret, bot) return ret @@ -289,3 +291,38 @@ def get_text_without_at_bot(msg): text = re.sub(r'\s*@' + re.escape(name) + r'\u2005?\s*', '', text) return text + + +def start_new_thread(target, args=(), kwargs=None, daemon=True, use_caller_name=False): + """ + 启动一个新的进程,需要时自动为进程命名,并返回这个线程 + + :param target: 调用目标 + :param args: 调用位置参数 + :param kwargs: 调用命名参数 + :param daemon: 作为守护进程 + :param use_caller_name: 为 True 则以调用者为名称,否则以目标为名称 + + :return: 新的进程 + :rtype: threading.Thread + """ + + if use_caller_name: + # 使用调用者的名称 + name = inspect.stack()[1][3] + else: + name = target.__name__ + + logging.getLogger( + # 使用外层的 logger + inspect.currentframe().f_back.f_globals.get('__name__') + ).debug('new thread: {}'.format(name)) + + _thread = threading.Thread( + target=target, args=args, kwargs=kwargs, + name=name, daemon=daemon + ) + + _thread.start() + + return _thread