Repository: youfou/wxpy Branch: master Commit: ab63e12da822 Files: 75 Total size: 151.7 KB Directory structure: gitextract_ckd9lwhg/ ├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docs/ │ ├── Makefile │ ├── bot.rst │ ├── chats.rst │ ├── conf.py │ ├── console.rst │ ├── faq.rst │ ├── index.rst │ ├── itchat.rst │ ├── logging_with_wechat.rst │ ├── make.bat │ ├── messages.rst │ ├── response_error.rst │ └── utils.rst ├── requirements.txt ├── setup.cfg ├── setup.py ├── tests/ │ ├── __init__.py │ ├── api/ │ │ ├── __init__.py │ │ ├── chats/ │ │ │ ├── __init__.py │ │ │ ├── test_chat.py │ │ │ ├── test_chats.py │ │ │ ├── test_friend.py │ │ │ ├── test_group.py │ │ │ ├── test_groups.py │ │ │ ├── test_member.py │ │ │ ├── test_mp.py │ │ │ └── test_user.py │ │ ├── messages/ │ │ │ ├── __init__.py │ │ │ └── test_message.py │ │ └── test_bot.py │ ├── attachments/ │ │ └── file.txt │ ├── conftest.py │ ├── ext/ │ │ └── __init__.py │ └── utils/ │ └── __init__.py └── wxpy/ ├── __compat__.py ├── __init__.py ├── __main__.py ├── api/ │ ├── __init__.py │ ├── bot.py │ ├── chats/ │ │ ├── __init__.py │ │ ├── chat.py │ │ ├── chats.py │ │ ├── friend.py │ │ ├── group.py │ │ ├── groups.py │ │ ├── member.py │ │ ├── mp.py │ │ └── user.py │ ├── consts.py │ └── messages/ │ ├── __init__.py │ ├── article.py │ ├── message.py │ ├── message_config.py │ ├── messages.py │ ├── registered.py │ └── sent_message.py ├── compatible/ │ ├── __init__.py │ └── utils.py ├── exceptions.py ├── ext/ │ ├── __init__.py │ ├── logging_with_wechat.py │ ├── sync_message_in_groups.py │ ├── talk_bot_utils.py │ ├── tuling.py │ └── xiaoi.py └── utils/ ├── __init__.py ├── base_request.py ├── console.py ├── misc.py ├── puid_map.py └── tools.py ================================================ FILE CONTENTS ================================================ ================================================ FILE: .gitignore ================================================ .idea *.pkl _installed_files.txt _private ### Linux template *~ # temporary files which can be created if a process still has a handle open of a deleted file .fuse_hidden* # KDE directory preferences .directory # Linux trash folder which might appear on any partition or disk .Trash-* # .nfs files are created when an open file is removed but is still being accessed .nfs* ### Windows template # Windows thumbnail cache files Thumbs.db ehthumbs.db ehthumbs_vista.db # Folder config file Desktop.ini # Recycle Bin used on file shares $RECYCLE.BIN/ # Windows Installer files *.cab *.msi *.msm *.msp # Windows shortcuts *.lnk ### JetBrains template # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 # Sensitive or high-churn files: .idea/**/dataSources/ .idea/**/dataSources.ids .idea/**/dataSources.xml .idea/**/dataSources.local.xml .idea/**/sqlDataSources.xml .idea/**/dynamic.xml .idea/**/uiDesigner.xml # Gradle: .idea/**/gradle.xml .idea/**/libraries # Mongo Explorer plugin: .idea/**/mongoSettings.xml ## File-based project format: *.iws ## Plugin-specific files: # IntelliJ /out/ # mpeltonen/sbt-idea plugin .idea_modules/ # JIRA plugin atlassian-ide-plugin.xml # Crashlytics plugin (for Android Studio and IntelliJ) com_crashlytics_export_strings.xml crashlytics.properties crashlytics-build.properties fabric.properties ### macOS template *.DS_Store .AppleDouble .LSOverride # Icon must end with two \r Icon # Thumbnails ._* # Files that might appear in the root of a volume .DocumentRevisions-V100 .fseventsd .Spotlight-V100 .TemporaryItems .Trashes .VolumeIcon.icns .com.apple.timemachine.donotpresent # Directories potentially created on remote AFP share .AppleDB .AppleDesktop Network Trash Folder Temporary Items .apdisk ### Python template # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class # C extensions *.so # Distribution / packaging .Python env/ build/ develop-eggs/ dist/ downloads/ eggs/ .eggs/ lib/ lib64/ parts/ sdist/ var/ wheels/ *.egg-info/ .installed.cfg *.egg # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec # Installer logs pip-log.txt pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ .coverage .coverage.* .cache nosetests.xml coverage.xml *,cover .hypothesis/ # Translations *.mo *.pot # Django stuff: *.log local_settings.py # Flask stuff: instance/ .webassets-cache # Scrapy stuff: .scrapy # Sphinx documentation docs/_build/ # PyBuilder target/ # Jupyter Notebook .ipynb_checkpoints # pyenv .python-version # celery beat schedule file celerybeat-schedule # SageMath parsed files *.sage.py # dotenv .env # virtualenv .venv venv/ ENV/ # Spyder project settings .spyderproject # Rope project settings .ropeproject wxpy/main.py ================================================ FILE: LICENSE ================================================ **The MIT License** Copyright 2017 [Youfou](https://github.com/youfou) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: MANIFEST.in ================================================ include README.rst LICENSE ================================================ FILE: README.rst ================================================ wxpy: 用 Python 玩微信 ============================== .. image:: https://badge.fury.io/py/wxpy.svg :target: https://badge.fury.io/py/wxpy .. image:: https://img.shields.io/pypi/pyversions/wxpy.svg :target: https://github.com/youfou/wxpy .. image:: https://readthedocs.org/projects/wxpy/badge/?version=latest :target: http://wxpy.readthedocs.io/zh/latest/?badge=latest 微信机器人 / 可能是最优雅的微信个人号 API wxpy 在 itchat 的基础上,通过大量接口优化提升了模块的易用性,并进行丰富的功能扩展 .. attention:: | **强烈建议仅使用小号运行机器人!** | 从近期 (17年6月下旬) 反馈来看,使用机器人存在一定概率被限制登录的可能性。 | 主要表现为无法登陆 Web 微信 (但不影响手机等其他平台)。 用来干啥 ---------------- 一些常见的场景 * 控制路由器、智能家居等具有开放接口的玩意儿 * 运行脚本时自动把日志发送到你的微信 * 加群主为好友,自动拉进群中 * 跨号或跨群转发消息 * 自动陪人聊天 * 逗人玩 * ... 总而言之,可用来实现各种微信个人号的自动化操作 .. 体验一下 ---------------- **这有一个现成的微信机器人,想不想调戏一下?** 记得填写入群口令 👉 [ **wxpy** ],与群里的大神们谈笑风生 😏 .. image:: https://github.com/youfou/wxpy/raw/master/docs/wechat-group.png 轻松安装 ---------------- wxpy 支持 Python 3.4-3.6,以及 2.7 版本 将下方命令中的 "pip" 替换为 "pip3" 或 "pip2",可确保安装到对应的 Python 版本中 1. 从 PYPI 官方源下载安装 (在国内可能比较慢或不稳定): .. code:: shell pip install -U wxpy 2. 从豆瓣 PYPI 镜像源下载安装 (**推荐国内用户选用**): .. code:: shell pip install -U wxpy -i "https://pypi.doubanio.com/simple/" 简单上手 ---------------- 登陆微信: .. code:: python # 导入模块 from wxpy import * # 初始化机器人,扫码登陆 bot = Bot() 找到好友: .. code:: python # 搜索名称含有 "游否" 的男性深圳好友 my_friend = bot.friends().search('游否', sex=MALE, city="深圳")[0] 发送消息: .. code:: python # 发送文本给好友 my_friend.send('Hello WeChat!') # 发送图片 my_friend.send_image('my_picture.jpg') 自动响应各类消息: .. code:: python # 打印来自其他好友、群聊和公众号的消息 @bot.register() def print_others(msg): print(msg) # 回复 my_friend 的消息 (优先匹配后注册的函数!) @bot.register(my_friend) def reply_my_friend(msg): return 'received: {} ({})'.format(msg.text, msg.type) # 自动接受新的好友请求 @bot.register(msg_types=FRIENDS) def auto_accept_friends(msg): # 接受好友请求 new_friend = msg.card.accept() # 向新的好友发送消息 new_friend.send('哈哈,我自动接受了你的好友请求') 保持登陆/运行: .. code:: python # 进入 Python 命令行、让程序保持运行 embed() # 或者仅仅堵塞线程 # bot.join() 模块特色 ---------------- * 全面对象化接口,调用更优雅 * 默认多线程响应消息,回复更快 * 包含 聊天机器人、共同好友 等 `实用组件 `_ * 只需两行代码,在其他项目中用微信接收警告 * `愉快的探索和调试 `_,无需涂涂改改 * 可混合使用 itchat 的原接口 * 当然,还覆盖了各类常见基本功能: * 发送文本、图片、视频、文件 * 通过关键词或用户属性搜索 好友、群聊、群成员等 * 获取好友/群成员的昵称、备注、性别、地区等信息 * 加好友,建群,邀请入群,移出群 说明文档 ---------------- http://wxpy.readthedocs.io 更新日志 ---------------- https://github.com/youfou/wxpy/releases 项目主页 ---------------- https://github.com/youfou/wxpy ================================================ FILE: docs/Makefile ================================================ # Minimal makefile for Sphinx documentation # # You can set these variables from the shell_entry line. SPHINXOPTS = SPHINXBUILD = sphinx-build SPHINXPROJ = wxpy SOURCEDIR = . BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) .PHONY: help Makefile # Catch-all target: route all unknown targets to Sphinx using the new # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). %: Makefile @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) ================================================ FILE: docs/bot.rst ================================================ 机器人对象 ============================== .. module:: wxpy 机器人 :class:`Bot` 对象可被理解为一个 Web 微信客户端。 .. note:: | 关于发送消息,请参见 :doc:`chats`。 | 关于消息对象和自动处理,请参见 :doc:`messages`。 初始化/登陆 ---------------- .. note:: :class:`Bot` 在初始化时便会执行登陆操作,需要手机扫描登陆。 .. autoclass:: Bot .. automethod:: Bot.enable_puid .. attribute:: Bot.auto_mark_as_read 为 True 时,将自动消除手机端的新消息小红点提醒 (默认为 False) 获取聊天对象 ---------------- .. attribute:: Bot.self 机器人自身 (作为一个聊天对象) 若需要给自己发送消息,请先进行以下一次性操作:: # 在 Web 微信中把自己加为好友 bot.self.add() bot.self.accept() # 发送消息给自己 bot.self.send('能收到吗?') .. attribute:: Bot.file_helper 文件传输助手 .. automethod:: Bot.friends .. automethod:: Bot.groups .. automethod:: Bot.mps .. automethod:: Bot.chats 搜索聊天对象 ---------------- .. note:: * 通过 `.search()` 获得的搜索结果 **均为列表** * 若希望找到唯一结果,可使用 :any:`ensure_one()` 搜索好友:: # 搜索名称包含 '游否' 的深圳男性好友 found = bot.friends().search('游否', sex=MALE, city='深圳') # [] # 确保搜索结果是唯一的,并取出唯一结果 youfou = ensure_one(found) # 搜索群聊:: # 搜索名称包含 'wxpy',且成员中包含 `游否` 的群聊对象 wxpy_groups = bot.groups().search('wxpy', [youfou]) # [, ] 在群聊中搜素:: # 在刚刚找到的第一个群中搜索 group = wxpy_groups[0] # 搜索该群中所有浙江的群友 found = group.search(province='浙江') # [, , ...] 搜索任何类型的聊天对象 (但不包含群内成员) :: # 搜索名称含有 'wxpy' 的任何聊天对象 found = bot.search('wxpy') # [, , ] 加好友和建群 ---------------- .. automethod:: Bot.add_friend .. automethod:: Bot.add_mp .. automethod:: Bot.accept_friend 自动接受好友请求:: # 注册好友请求类消息 @bot.register(msg_types=FRIENDS) # 自动接受验证信息中包含 'wxpy' 的好友请求 def auto_accept_friends(msg): # 判断好友请求中的验证文本 if 'wxpy' in msg.text.lower(): # 接受好友 (msg.card 为该请求的用户对象) new_friend = bot.accept_friend(msg.card) # 或 new_friend = msg.card.accept() # 向新的好友发送消息 new_friend.send('哈哈,我自动接受了你的好友请求') .. automethod:: Bot.create_group 其他 ---------------- .. automethod:: Bot.user_details .. automethod:: Bot.upload_file .. automethod:: Bot.join .. automethod:: Bot.logout 控制多个微信 (多开) -------------------------------- 仅需初始化多个 :class:`Bot` 对象,即可同时控制多个微信:: bot1 = Bot() bot2 = Bot() ================================================ FILE: docs/chats.rst ================================================ 聊天对象 ============================== .. module:: wxpy 通过机器人对象 :class:`Bot ` 的 :meth:`chats() `, :meth:`friends() `,:meth:`groups() `, :meth:`mps() ` 方法, 可分别获取到当前机器人的 所有聊天对象、好友、群聊,以及公众号列表。 而获得到的聊天对象合集 :class:`Chats` 和 :class:`Groups` 具有一些合集方法,例如::meth:`Chats.search` 可用于按条件搜索聊天对象:: from wxpy import * bot = Bot() my_friend = bot.friends().search('游否', sex=MALE, city='深圳')[0] # 在找到好友(或其他聊天对象)后,还可使用该聊天对象的 :meth:`send ` 系列方法,对其发送消息:: # 发送文本 my_friend.send('Hello, WeChat!') # 发送图片 my_friend.send_image('my_picture.png') # 发送视频 my_friend.send_video('my_video.mov') # 发送文件 my_friend.send_file('my_file.zip') # 以动态的方式发送图片 my_friend.send('@img@my_picture.png') 各类型的继承关系 -------------------------------------- 在继续了解各个聊天对象之前,我们需要首先 **理解各种不同类型聊天对象的继承关系** 基础类 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 所有聊天对象,均继承于以下两种基础类,并拥有相应的属性和方法。 基本聊天对象 :class:`Chat` * 所有的聊天对象均继承于此类型 * 拥有 微信ID、昵称 等属性 * 拥有 发送消息 :meth:`Chat.send`, 获取头像 :meth:`Chat.get_avatar` 等方法 单个聊天对象 :class:`User` * 继承于 :class:`Chat`,表示个体聊天对象 (而非群聊)。 * 被以下聊天对象所继承 * 好友对象 :class:`Friend` * 群成员对象 :class:`Member` * 公众号对象 :class:`MP` * 拥有 性别、省份、城市、是否为好友 等属性 * 拥有 加为好友 :meth:`User.add`, 接受为好友 :meth:`User.accept` 等方法 实际类 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 在实际使用过程中,我们会更多的用到以下实际聊天对象类型。 .. tip:: 请牢记,除了自身私有的属性和方法外,它们还拥有对应基础类的属性和方法 (未重复列出)。 * 好友 :class:`Friend` * 群聊 :class:`Group` * 群成员 :class:`Member` * 公众号 :class:`MP` .. note:: **阅读以下内容,你将了解:** * 如何获取他们的各种属性 (ID、昵称、性别、地区、是否为好友关系等) * 如何对他们进行发送消息、加为好友、加入群聊、下载头像 等操作 基本聊天对象 -------------------------------------- 所有聊天对象都继承于"基本聊天对象",并拥有相应的属性和方法。 .. autoclass:: Chat :members: .. attribute:: bot 所属的 :class:`机器人对象 ` .. attribute:: raw 原始数据 单个聊天对象 -------------------------------------- .. autoclass:: User :members: 好友 ------------------- .. autoclass:: Friend :members: 群聊 ------------------- .. autoclass:: Group :members: 群成员 ^^^^^^^^^^^^^^^^^^^^ .. autoclass:: Member :members: 实用技巧 ^^^^^^^^^^^^^^^^^^^^ 判断一位用户是否在群中只需用 `in` 语句:: friend = bot.friends().search('游否')[0] group = bot.groups().search('wxpy 交流群')[0] if friend in group: print('是的,{} 在 {} 中!'.format(friend.name, group.name)) # 是的,游否 在 wxpy 交流群 中! 若要遍历群成员,可直接对群对象使用 `for` 语句:: # 打印所有群成员 for member in group: print(member) 若需查看群成员数量,直接使用 `len()` 即可:: len(group) # 这个群的成员数量 若需判断一位群成员是否就是某个好友,使用 `==` 即可:: member = group.search('游否')[0] if member == friend: print('{} is {}'.format(member, friend)) # is 公众号 ------------------- .. autoclass:: MP :members: 聊天对象合集 ------------------- 好友、公众号、群聊成员的合集 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 在 :class:`Chats` 对象中,除了最常用到的 :meth:`search() ` 外,还有两个特别的方法,:meth:`stats() ` 与 :meth:`stats_text() `,可用来统计好友或群成员的性别和地区分布:: bot.friends().stats_text() # 游否 共有 100 位微信好友\n\n男性: 67 (67.0%)\n女性: 23 (23.0%) ... .. autoclass:: Chats :members: 群聊的合集 ^^^^^^^^^^^^^^^^^^^^ .. autoclass:: Groups :members: ================================================ FILE: docs/conf.py ================================================ #!/usr/bin/env python3 # -*- coding: utf-8 -*- # # wxpy documentation build configuration file, created by # sphinx-quickstart on Sat Feb 25 23:57:26 2017. # # This file is execfile()d with the current directory set to its # containing dir. # # Note that not all possible configuration values are present in this # autogenerated file. # # All configuration values have a default; values that are commented out # serve to show the default. # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. # import os import sys sys.path.insert(0, os.path.abspath('..')) import wxpy # -- General configuration ------------------------------------------------ # If your documentation needs a minimal Sphinx version, state it here. # # needs_sphinx = '1.0' # Add any Sphinx extension module names here, as strings. They can be # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.coverage', 'sphinx.ext.viewcode', ] # For Read the Docs on_rtd = os.environ.get('READTHEDOCS', None) == 'True' if on_rtd: html_theme = 'default' else: import sphinx_rtd_theme html_theme = 'sphinx_rtd_theme' html_theme_path = [sphinx_rtd_theme.get_html_theme_path()] extensions.append('sphinx.ext.githubpages') # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] # The suffix(es) of source filenames. # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] source_suffix = '.rst' # The master toctree document. master_doc = 'index' # General information about the project. project = 'wxpy' copyright = wxpy.__copyright__ author = wxpy.__author__ # The version info for the project you're documenting, acts as replacement for # |version| and |release|, also used in various other places throughout the # built documents. # # The short X.Y version. version = wxpy.__version__ # The full version, including alpha/beta/rc tags. release = wxpy.__version__ # The language for content autogenerated by Sphinx. Refer to documentation # for a list of supported languages. # # This is also used if you do content translation via gettext catalogs. # Usually you set "language" from the shell_entry line for these cases. language = 'zh_CN' # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. # This patterns also effect to html_static_path and html_extra_path exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] # The name of the Pygments (syntax highlighting) style to use. pygments_style = 'sphinx' # If true, `todo` and `todoList` produce output, else they produce nothing. todo_include_todos = False # -- Options for HTML output ---------------------------------------------- # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. # # html_theme = 'nature' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the # documentation. # # html_theme_options = {} # Add any paths that contain custom static files (such as style sheets) here, # relative to this directory. They are copied after the builtin static files, # so a file named "default.css" will overwrite the builtin "default.css". html_static_path = ['_static'] # -- Options for HTMLHelp output ------------------------------------------ # Output file base name for HTML help builder. htmlhelp_basename = 'wxpydoc' # -- Options for LaTeX output --------------------------------------------- latex_elements = { # The paper size ('letterpaper' or 'a4paper'). # # 'papersize': 'letterpaper', # The font size ('10pt', '11pt' or '12pt'). # # 'pointsize': '10pt', # Additional stuff for the LaTeX preamble. # # 'preamble': '', # Latex figure (float) alignment # # 'figure_align': 'htbp', } # Grouping the document tree into LaTeX files. List of tuples # (source start file, target name, title, # author, documentclass [howto, manual, or own class]). latex_documents = [ (master_doc, 'wxpy.tex', 'wxpy Documentation', author, 'manual'), ] # -- Options for manual page output --------------------------------------- # One entry per manual page. List of tuples # (source start file, name, description, authors, manual section). man_pages = [ (master_doc, 'wxpy', 'wxpy Documentation', [author], 1) ] # -- Options for Texinfo output ------------------------------------------- # Grouping the document tree into Texinfo files. List of tuples # (source start file, target name, title, author, # dir menu entry, description, category) texinfo_documents = [ (master_doc, 'wxpy', 'wxpy 文档', author, 'wxpy', '微信个人号 API,用 Python 玩微信', 'API'), ] # -- Options for Epub output ---------------------------------------------- # Bibliographic Dublin Core info. epub_title = project epub_author = author epub_publisher = author epub_copyright = copyright # The unique identifier of the text. This can be a ISBN number # or the project homepage. # # epub_identifier = '' # A unique identification for the text. # # epub_uid = '' # A list of files that should not be packed into the epub file. epub_exclude_files = ['search.html'] autoclass_content = 'both' autodoc_member_order = 'bysource' suppress_warnings = ['image.nonlocal_uri'] ================================================ FILE: docs/console.rst ================================================ 愉快的探索和调试 ============================== .. module:: wxpy 想要做点小试验,调试代码,或是探索 wxpy 的功能特性?反复修改和运行太麻烦。 试试下面两种玩法,告别涂涂改改的摸索方式。 使用 `embed()` ------------------------------ .. note:: 适用于在现有的代码中进行探索和调试 只需将 :any:`embed()` 放在代码中的任何位置。运行后,就可以从那儿开始探索和调试。 例如,初始化一个机器人,然后看看它能做些什么:: from wxpy import * bot = Bot() embed() # 进入 Python 命令行 # 输入对象名称并回车 >>> bot # Out[1]: >>> bot.friends() # Out[2]: [, , ] .. autofunction:: embed :noindex: 使用 `wxpy` 命令 ------------------------------ .. highlight:: shell .. note:: 适用于在命令行中边写边探索 第二种情况:想要简单写几行,而不想创建脚本,那么使用 `wxpy` 命令行边写边探索,更方便。 在命令行中输入 `wxpy -h` 可快速查看使用说明。 选项 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ * bot1 bot2 bot3... * 一个或多个需要初始化的机器人对象的名称,以空格分割 * 默认:不初始化机器人 * 例子: `bot1 bot2` * -c / --cache * 使用会话缓存功能,将创建 `wxpy_*.pkl` 缓存文件 * 默认:不缓存会话 * 例子:`-c` * -q 宽度 / --console_qr 宽度 * 终端二维码的单元格宽度 * 默认:不使用终端二维码 * 例子:`-q 2` * -l 等级 / --logging_level 等级 (注意是小写 L,不是 I) * 日志等级 * 默认:`INFO` * 例子:`-l DEBUG` * -s 交互界面 / --shell 交互界面 * 选择所需使用的 Python 交互界面 * 可为:`ipython`,`bpython`,`python`,或它们的首字母 * 默认:以上首个可用的 Python 命令行 * 例子:`-s bpython` * -v / --version * 展示版本信息并退出z * 例子:`-v` 例子 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 初始化一个名为 `bot` 的机器人:: wxpy bot 在此基础上,使用终端二维码,且单元格宽度为 2:: wxpy bot -q 2 分别初始化名为 `bot1` 和 `bot2` 的两个机器人:: wxpy bot1 bot2 在此基础上,使用会话缓存功能:: wxpy bot1 bot2 -c 在此基础上,指定使用 bpython:: wxpy bot1 bot2 -c -s bpython ================================================ FILE: docs/faq.rst ================================================ 必看: 常见问题 FAQ ============================== .. module:: wxpy .. hint:: 这里罗列了一些常见的问题,在提出新的问题前,请先看完本文。 启动后马上退出了? -------------------------------- 因为主线程执行完成了,程序自然会退出。 只需在代码结尾加一句 :any:`embed()` 即可堵塞线程,还能进入 Python 命令行:: from wxpy import * # 你的其他代码... # 堵塞线程,并进入 Python 命令行 embed() 或者,也可以使用 :any:`Bot.join()` 仅仅堵塞线程:: bot = Bot() # 你的其他代码... # 仅仅堵塞线程 bot.join() # 机器人登出后会继续往下执行 每次登陆都要扫码? -------------------------------- 可启用登陆状态缓存功能,在短时间内重新运行程序,可自动登录。 具体请见 :any:`Bot` 中的 `cache_path` 参数说明。 可以在 Linux 中使用吗? ---------------------------------------------------------------- wxpy 不依赖于图形界面,因此完全兼容各种纯终端的服务器。 但有一点需要注意,在纯终端环境中,登陆时必须使用"终端二维码"参数。 具体请见 :any:`Bot` 中的 `console_qr` 参数说明。 .. tip:: 遇到以下错误?请使用 :any:`Bot` 的 `console_qr` 参数。 :: FileNotFoundError: [Errno 2] No such file or directory: 'xdg-open' 支持 红包、转账、朋友圈… 吗? -------------------------------- wxpy 使用了 Web 微信的通讯协议,因此仅能覆盖 Web 微信本身所具备的功能。 所以以下功能目前 **均不支持** * 支付相关 - 红包、转账、收款 等都不支持 * 在群聊中@他人 - 是的,Web 微信中被人@后也不会提醒 * 发送名片 - 但可以通过 :any:`send_raw_msg()` 转发 * 发送分享链接 - 也无法转发 * 发送语音消息 * 朋友圈相关 为什么要开发 wxpy? -------------------------------- wxpy 的初衷是帮助人们利用微信来使生活和工作更轻松。 .. note:: 希望每位使用者在使用机器人时 * 维护良好的交流环境 * 永远不骚扰他人 * 遵守法律和平台规则 ================================================ FILE: docs/index.rst ================================================ .. wxpy documentation master file, created by sphinx-quickstart on Sat Feb 25 23:57:26 2017. You can adapt this file completely to your liking, but it should at least contain the root `toctree` directive. wxpy: 用 Python 玩微信 ============================== .. image:: https://badge.fury.io/py/wxpy.svg :target: https://pypi.python.org/pypi/wxpy/ .. image:: https://img.shields.io/pypi/pyversions/wxpy.svg :target: https://github.com/youfou/wxpy .. image:: https://readthedocs.org/projects/wxpy/badge/?version=latest :target: http://wxpy.readthedocs.io/zh/latest/?badge=latest 微信机器人 / 可能是最优雅的微信个人号 API wxpy 在 itchat 的基础上,通过大量接口优化提升了模块的易用性,并进行丰富的功能扩展 .. attention:: | **强烈建议仅使用小号运行机器人!** | 从近期 (17年6月下旬) 反馈来看,使用机器人存在一定概率被限制登录的可能性。 | 主要表现为无法登陆 Web 微信 (但不影响手机等其他平台)。 项目主页 ---------------- https://github.com/youfou/wxpy 用来干啥 ---------------- 一些常见的场景 * 控制路由器、智能家居等具有开放接口的玩意儿 * 运行脚本时自动把日志发送到你的微信 * 加群主为好友,自动拉进群中 * 跨号或跨群转发消息 * 自动陪人聊天 * 逗人玩 * ... 总而言之,可用来实现各种微信个人号的自动化操作 .. 体验一下 ---------------- **这有一个现成的微信机器人,想不想调戏一下?** 记得填写入群口令 👉 [ **wxpy** ],与群里的大神们谈笑风生 😏 .. image:: wechat-group.png 轻松安装 ---------------- wxpy 支持 Python 3.4-3.6,以及 2.7 版本 将下方命令中的 "pip" 替换为 "pip3" 或 "pip2",可确保安装到对应的 Python 版本中 1. 从 PYPI 官方源下载安装 (在国内可能比较慢或不稳定): .. code:: shell pip install -U wxpy 2. 从豆瓣 PYPI 镜像源下载安装 (**推荐国内用户选用**): .. code:: shell pip install -U wxpy -i "https://pypi.doubanio.com/simple/" 简单上手 ---------------- .. automodule:: wxpy 模块特色 ---------------- * 全面对象化接口,调用更优雅 * 默认多线程响应消息,回复更快 * 包含 聊天机器人、共同好友 等 :doc:`实用组件 ` * 只需两行代码,在其他项目中 :doc:`用微信接收警告 ` * :doc:`愉快的探索和调试 `,无需涂涂改改 * 可混合使用 itchat 的原接口 * 当然,还覆盖了各类常见基本功能: * 发送文本、图片、视频、文件 * 通过关键词或用户属性搜索 好友、群聊、群成员等 * 获取好友/群成员的昵称、备注、性别、地区等信息 * 加好友,建群,邀请入群,移出群 更新日志 ---------------- https://github.com/youfou/wxpy/releases 文档目录 ---------------- .. toctree:: :maxdepth: 2 bot chats messages logging_with_wechat console utils response_error itchat faq ================================================ FILE: docs/itchat.rst ================================================ itchat 与原始数据 ============================== .. module:: wxpy 正是得益于 |itchat| 的坚实基础,wxpy 才能够在短时间内快速实现这些新的接口和功能。 感谢 itchat 维护者们的辛勤付出。 以下为如何在 wxpy 中混合使用 itchat 的原接口和原始数据。 .. |itchat| raw:: html itchat 使用 itchat 的原接口 ------------------------------ 只需在 wxpy 的 :class:`Bot` 对象后紧跟 `.core.*` 即可调用 itchat 的原接口。 例如,使用 itchat 的 `search_friends` 接口:: from wxpy import * bot = Bot() found = bot.core.search_friends('游否') .. attention:: 通过 itchat 原接口所获取到的结果为原始数据,可能无法直接传递到 wxpy 的对应方法中。 使用原始数据 ------------------------------ wxpy 的所有 **聊天对象** 和 **消息对象** 均基于从 itchat 获取到的数据进行封装。若需使用原始数据,只需在对象后紧跟 `.raw`。 例如,查看一个 :class:`好友 ` 对象的原始数据:: from wxpy import * bot = Bot() a_friend = bot.friends()[0] print(a_friend.raw) ================================================ FILE: docs/logging_with_wechat.rst ================================================ 用微信监控你的程序 ============================== .. module:: wxpy 通过利用微信强大的通知能力,我们可以把程序中的警告/日志发到自己的微信上。 wxpy 提供以下两种方式来实现这个需求。 获得专用 Logger ------------------------------ .. autofunction:: get_wechat_logger :: from wxpy import get_wechat_logger # 获得一个专用 Logger # 当不设置 `receiver` 时,会将日志发送到随后扫码登陆的微信的"文件传输助手" logger = get_wechat_logger() # 发送警告 logger.warning('这是一条 WARNING 等级的日志,你收到了吗?') # 接收捕获的异常 try: 1 / 0 except: logger.exception('现在你又收到了什么?') 加入到现有的 Logger ------------------------------ .. autoclass:: WeChatLoggingHandler :: import logging from wxpy import WeChatLoggingHandler # 这是你现有的 Logger logger = logging.getLogger(__name__) # 初始化一个微信 Handler wechat_handler = WeChatLoggingHandler() # 加到入现有的 Logger logger.addHandler(wechat_handler) logger.warning('你有一条新的告警,请查收。') 指定接收者 ------------------------------ 当然,我们也可以使用其他聊天对象来接收日志。 比如,先在微信中建立一个群聊,并在里面加入需要关注这些日志的人员。然后把这个群作为接收者。 :: from wxpy import * # 初始化机器人 bot = Bot() # 找到需要接收日志的群 -- `ensure_one()` 用于确保找到的结果是唯一的,避免发错地方 group_receiver = ensure_one(bot.groups().search('XX业务-告警通知')) # 指定这个群为接收者 logger = get_wechat_logger(group_receiver) logger.error('打扰大家了,但这是一条重要的错误日志...') ================================================ FILE: docs/make.bat ================================================ @ECHO OFF pushd %~dp0 REM Command file for Sphinx documentation if "%SPHINXBUILD%" == "" ( set SPHINXBUILD=sphinx-build ) set SOURCEDIR=. set BUILDDIR=_build set SPHINXPROJ=wxpy if "%1" == "" goto help %SPHINXBUILD% >NUL 2>NUL if errorlevel 9009 ( echo. echo.The 'sphinx-build' shell_entry was not found. Make sure you have Sphinx echo.installed, then set the SPHINXBUILD environment variable to point echo.to the full path of the 'sphinx-build' executable. Alternatively you echo.may add the Sphinx directory to PATH. echo. echo.If you don't have Sphinx installed, grab it from echo.http://sphinx-doc.org/ exit /b 1 ) %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% goto end :help %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% :end popd ================================================ FILE: docs/messages.rst ================================================ 消息处理 ============================== .. module:: wxpy 每当机器人接收到消息时,会自动执行以下两个步骤 1. 将消息保存到 :class:`Bot.messages` 中 2. 查找消息预先注册的函数,并执行(若有匹配的函数) 消息对象 ---------------- 消息对象代表每一条从微信获取到的消息。 基本属性 ^^^^^^^^^^^^^^^^ .. autoattribute:: Message.type .. attribute:: Message.bot 接收此消息的 :class:`机器人对象 ` .. autoattribute:: Message.id 内容数据 ^^^^^^^^^^^^^^^^ .. autoattribute:: Message.text .. automethod:: Message.get_file .. autoattribute:: Message.file_name .. autoattribute:: Message.file_size .. autoattribute:: Message.media_id .. attribute:: Message.raw 原始数据 (dict 数据) 用户相关 ^^^^^^^^^^^^^^^^ .. autoattribute:: Message.chat .. autoattribute:: Message.sender .. autoattribute:: Message.receiver .. autoattribute:: Message.member .. autoattribute:: Message.card 群聊相关 ^^^^^^^^^^^^^^^^ .. autoattribute:: Message.member .. autoattribute:: Message.is_at 时间相关 ^^^^^^^^^^^^^^^^^ .. autoattribute:: Message.create_time .. autoattribute:: Message.receive_time .. autoattribute:: Message.latency 其他属性 ^^^^^^^^^^^^^^^^ .. autoattribute:: Message.url .. autoattribute:: Message.articles .. autoattribute:: Message.location .. autoattribute:: Message.img_height .. autoattribute:: Message.img_width .. autoattribute:: Message.play_length .. autoattribute:: Message.voice_length 回复方法 ^^^^^^^^^^^^^^^^ .. method:: Message.reply(...) 等同于 :meth:`Message.chat.send(...) ` .. method:: Message.reply_image(...) 等同于 :meth:`Message.chat.send_image(...) ` .. method:: Message.reply_file(...) 等同于 :meth:`Message.chat.send_file(...) ` .. method:: Message.reply_video(...) 等同于 :meth:`Message.chat.send_video(...) ` .. method:: Message.reply_msg(...) 等同于 :meth:`Message.chat.send_msg(...) ` .. method:: Message.reply_raw_msg(...) 等同于 :meth:`Message.chat.send_raw_msg(...) ` 转发消息 ^^^^^^^^^^^^^^^^ .. automethod:: Message.forward 自动处理消息 --------------------- 可通过 **预先注册** 的方式,实现消息的自动处理。 "预先注册" 是指 预先将特定聊天对象的特定类型消息,注册到对应的处理函数,以实现自动回复等功能。 注册消息 ^^^^^^^^^^^^^^ .. hint:: | 每当收到新消息时,将根据注册规则找到匹配条件的执行函数。 | 并将 :class:`消息对象 ` 作为唯一参数传入该函数。 将 :meth:`Bot.register` 作为函数的装饰器,即可完成注册。 :: # 打印所有*群聊*对象中的*文本*消息 @bot.register(Group, TEXT) def print_group_msg(msg): print(msg) .. attention:: 优先匹配 **后注册** 的函数,且仅匹配 **一个** 注册函数。 .. automethod:: Bot.register .. tip:: 1. `chats` 和 `msg_types` 参数可以接收一个列表或干脆一个单项。按需使用,方便灵活。 2. `chats` 参数既可以是聊天对象实例,也可以是对象类。当为类时,表示匹配该类型的所有聊天对象。 3. 在被注册函数中,可以通过直接 `return <回复内容>` 的方式来回复消息,等同于调用 `msg.reply(<回复内容>)`。 开始运行 ^^^^^^^^^^^^^^ .. note:: | 在完成注册操作后,若没有其他操作,程序会因主线程执行完成而退出。 | **因此务必堵塞线程以保持监听状态!** | wxpy 的 :any:`embed()` 可在堵塞线程的同时,进入 Python 命令行,方便调试,一举两得。 :: from wxpy import * bot = Bot() @bot.register() def print_messages(msg): print(msg) # 堵塞线程,并进入 Python 命令行 embed() .. autofunction:: embed 示例代码 ^^^^^^^^^^^^^ 在以下例子中,机器人将 * 忽略 "一个无聊的群" 的所有消息 * 回复好友 "游否" 和其他群聊中被 @ 的 TEXT 类消息 * 打印所有其他消息 初始化机器人,并找到好友和群聊:: from wxpy import * bot = Bot() my_friend = bot.friends().search('游否')[0] boring_group = bot.groups().search('一个无聊的群')[0] 打印所有其他消息:: @bot.register() def just_print(msg): # 打印消息 print(msg) 回复好友"游否"和其他群聊中被 @ 的 TEXT 类消息:: @bot.register([my_friend, Group], TEXT) def auto_reply(msg): # 如果是群聊,但没有被 @,则不回复 if isinstance(msg.chat, Group) and not msg.is_at: return else: # 回复消息内容和类型 return '收到消息: {} ({})'.format(msg.text, msg.type) 忽略"一个无聊的群"的所有消息:: @bot.register(boring_group) def ignore(msg): # 啥也不做 return 堵塞线程,并进入 Python 命令行:: embed() 动态开关注册配置 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. note:: 该操作需要在额外的线程中进行! 查看当前的注册配置情况:: bot.registered # [, # , # ] 关闭所有注册配置:: bot.registered.disable() 重新开启 `just_print` 函数:: bot.registered.enable(just_print) 查看当前开启的注册配置:: bot.registered.enabled # [] .. autoclass:: wxpy.api.messages.Registered :members: 已发送消息 ---------------- .. autoclass:: SentMessage .. hint:: 大部分属性与 :class:`Message` 相同 .. automethod:: recall 历史消息 ---------------- 可通过访问 `bot.messages` 来查看历史消息列表。 消息列表为 :class:`Messages` 对象,具有搜索功能。 例如,搜索所有自己在手机上发出的消息:: sent_msgs = bot.messages.search(sender=bot.self) print(sent_msgs) .. autoclass:: Messages .. attribute:: max_history 设置最大保存条数,即:仅保存最后的 n 条消息。 :: bot = Bot() # 设置历史消息的最大保存数量为 10000 条 bot.messages.max_history = 10000 .. automethod:: search :: # 搜索所有自己发送的,文本中包含 'wxpy' 的消息 bot.messages.search('wxpy', sender=bot.self) ================================================ FILE: docs/response_error.rst ================================================ 异常处理 ============================== 异常的抛出和捕捉 -------------------- .. module:: wxpy 每当使用 wxpy 向微信发出请求 (例如发送消息、加好友、建群等操作),wxpy 都会在收到服务端响应后进行检查。 若响应中的错误码不为 0,程序将抛出 :class:`ResponseError` 异常。 .. autoclass:: ResponseError .. attribute:: err_code 错误码 (int) .. attribute:: err_msg 错误消息 (文本),但可能为空 捕捉异常:: try: # 尝试向某个群员发送消息 group.members[3].send('Hello') except ResponseError as e: # 若群员还不是好友,将抛出 ResponseError 错误 print(e.err_code, e.err_msg) # 查看错误号和错误消息 已知错误码 -------------------- 通常来说,每个错误码表示一种类型的错误。 但因微信未公开 (也没有义务公开) 这套错误码体系的具体说明,我们只能根据经验猜测部分错误码的定义。 以下为一些常见的已知错误码。欢迎提交 PR `进行完善`_。 .. _进行完善: https://github.com/youfou/wxpy/blob/master/docs/response_error.rst 1205 ^^^^^^^^^^^^^^^^^^^^^^^ 通常因为操作频率过高。需要控制频率,避免再次引起该错误。 .. attention:: Web 微信对 **加好友、建群** 这两种操作的频率限制尤其严格! 对于微信而言,为了机器人避免打扰其他用户,以及控制服务器的负载压力,需要对各种不同的操作进行频率限制。 通常每种操作可有多层频率限制,而每层频率限制分为两个参数: 周期、次数,分布表示: 在 x 周期内,只能发送 y 个请求。 举个例子: 对于 **发送消息** 操作,可能会是这样 (数值为虚构): +----+----------+----------+ | 层 | 限制周期 | 限制次数 | +----+----------+----------+ | 1 | 2 分钟 | 120 | +----+----------+----------+ | 2 | 10 分钟 | 300 | +----+----------+----------+ | 3 | 1 小时 | 1000 | +----+----------+----------+ | 4 | 24 小时 | 2000 | +----+----------+----------+ | 可能会有用户在 1 分钟内狂发 100 条消息。 | 但这样的频率不可能维持一整天,所以一天内 3000 条是足够的。 通过以上方式,微信可实现较为合理的限制。 1204 ^^^^^^^^^^^^^^^^^^^^^^^ 通常因为操作对象不为好友关系。例如尝试向一位不为好友的群员发送消息时,会引起这个错误。 1100, 1101, 1102 ^^^^^^^^^^^^^^^^^^^^^^^ 通常表示机器人已经掉线,需要重新登录。 请重新初始化 :class:`Bot` 对象,并重新注册消息。 因为重新登录后,聊天对象的 `user_name` 可能已经变化,所以原先的消息注册也会因此失效。 ================================================ FILE: docs/utils.rst ================================================ 实用组件 ============================== .. module:: wxpy 额外内置了一些实用的小组件,可按需使用。 聊天机器人 ------------------------------ 目前提供了以下两种自动聊天机器人接口。 图灵 ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. autoclass:: Tuling :members: :: bot = Bot() my_friend = ensure_one(bot.search('游否')) tuling = Tuling(api_key='你申请的 API KEY') # 使用图灵机器人自动与指定好友聊天 @bot.register(my_friend) def reply_my_friend(msg): tuling.do_reply(msg) 小 i ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. autoclass:: XiaoI :members: :: bot = Bot() my_friend = ensure_one(bot.search('寒风')) xiaoi = XiaoI('你申请的 Key', '你申请的 Secret') # 使用小 i 机器人自动与指定好友聊天 @bot.register(my_friend) def reply_my_friend(msg): xiaoi.do_reply(msg) 查找共同好友 ------------------------------ .. autofunction:: mutual_friends :: bot1 = Bot() bot2 = Bot() # 打印共同好友 for mf in mutual_friends(bot, bot2): print(mf) 确保查找结果的唯一性 ------------------------------ .. autofunction:: ensure_one :: bot = Bot() # 确保只找到了一个叫"游否"的好友,并返回这个好友 my_friend = ensure_one(bot.search('游否')) # 在多个群中同步消息 ------------------------------ .. autofunction:: sync_message_in_groups 检测频率限制 ------------------------------ .. autofunction:: detect_freq_limit 例如,测试发送文本消息的频率限制:: bot = Bot('test.pkl') # 定义需要检测的操作 def action(): bot.file_helper.send() # 执行检测 result = detect_freq_limit(action) # 查看结果 print(result) # (120, 120.111222333) 忽略 `ResponseError` 异常 ------------------------------ .. autofunction:: dont_raise_response_error ================================================ FILE: requirements.txt ================================================ itchat==1.2.32 requests future ================================================ FILE: setup.cfg ================================================ [aliases] release=sdist build egg_info upload ================================================ FILE: setup.py ================================================ # coding: utf-8 # from __future__ import unicode_literals import re import codecs from setuptools import find_packages, setup with codecs.open('wxpy/__init__.py', encoding='utf-8') as fp: version = re.search(r"__version__\s*=\s*'([\w\-.]+)'", fp.read()).group(1) with codecs.open('README.rst', encoding='utf-8') as fp: readme = fp.read() setup( name='wxpy', version=version, packages=find_packages(), include_package_data=True, entry_points={ 'console_scripts': [ 'wxpy = wxpy.utils:shell_entry' ] }, install_requires=[ 'itchat==1.2.32', 'requests', 'future', ], tests_require=[ 'pytest', ], url='https://github.com/youfou/wxpy', license='MIT', author='Youfou', author_email='youfou@qq.com', description='微信机器人 / 可能是最优雅的微信个人号 API', long_description=readme, keywords=[ '微信', 'WeChat', 'API' ], classifiers=[ 'Development Status :: 4 - Beta', 'License :: OSI Approved :: MIT License', 'Programming Language :: Python', 'Programming Language :: Python :: 2.7', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', 'Programming Language :: Python :: 3.6', 'Operating System :: OS Independent', 'Topic :: Communications :: Chat', 'Topic :: Utilities', ] ) ================================================ FILE: tests/__init__.py ================================================ ================================================ FILE: tests/api/__init__.py ================================================ ================================================ FILE: tests/api/chats/__init__.py ================================================ ================================================ FILE: tests/api/chats/test_chat.py ================================================ class TestChat: def test_pin_unpin(self, friend): friend.pin() friend.unpin() ================================================ FILE: tests/api/chats/test_chats.py ================================================ from collections import Counter from wxpy import * class TestChats: def test_search(self, group, friend): found = group.search('wxpy 机器人') assert friend in found assert isinstance(found, Chats) def test_stats(self, group): stats = group.members.stats() assert isinstance(stats, dict) for attr in 'province', 'city', 'sex': assert attr in stats assert isinstance(stats[attr], Counter) def test_stats_text(self, group): text = group.members.stats_text() assert '位群成员' in text ================================================ FILE: tests/api/chats/test_friend.py ================================================ class TestFriend: pass ================================================ FILE: tests/api/chats/test_group.py ================================================ import pytest from wxpy import * class TestGroup: def test_group_attributes(self, group, friend, member): isinstance(group.members, Chats) assert friend in group assert member in group assert group.self == group.bot.self assert group.self in group assert not group.is_owner assert group.owner == friend def test_update_group(self, group): group.update_group(members_details=True) assert group.members[-1].sex is not None def test_add_members(self, group, member): try: group.add_members(member) except ResponseError as e: if e.err_code != 1205: raise e def test_remove_members(self, member): with pytest.raises(ResponseError) as e: member.remove() assert e.err_code == -66 ================================================ FILE: tests/api/chats/test_groups.py ================================================ class TestGroups: def test_search(self, bot, group, member, friend): found = bot.groups().search(group.name, users=[bot.self, member, friend]) assert group in found ================================================ FILE: tests/api/chats/test_member.py ================================================ ================================================ FILE: tests/api/chats/test_mp.py ================================================ class TestMP: pass ================================================ FILE: tests/api/chats/test_user.py ================================================ import re from wxpy import * class TestUser: def test_friend_attributes(self, friend): assert isinstance(friend, Friend) assert friend.nick_name == 'wxpy 机器人' assert friend.wxid in ('wxpy_bot', None) assert friend.province == '广东' assert friend.city == '深圳' assert friend.sex == MALE assert friend.signature == '如果没有正确响应,可能正在调试中…' assert re.match(r'@[\da-f]{32,}', friend.user_name) assert friend.is_friend # def test_add(self, member): # member.add('wxpy tests: test_add') def test_accept(self, member): # 似乎只要曾经是好友,就可以调用这个方法,达到"找回已删除的好友"的效果 member.accept() def test_remark_name(self, friend, member): new_remark_name = '__test__123__' for user in friend, member: current_remark_name = user.remark_name or '' for remark_name in new_remark_name, current_remark_name: user.set_remark_name(remark_name) ================================================ FILE: tests/api/messages/__init__.py ================================================ ================================================ FILE: tests/api/messages/test_message.py ================================================ from datetime import datetime from tests.conftest import wait_for_message from wxpy import * def sent_message(sent_msg, msg_type, receiver): assert isinstance(sent_msg, SentMessage) assert sent_msg.type == msg_type assert sent_msg.receiver == receiver assert sent_msg.bot == receiver.bot assert sent_msg.sender == receiver.bot.self assert isinstance(sent_msg.receive_time, datetime) assert isinstance(sent_msg.create_time, datetime) assert sent_msg.create_time < sent_msg.receive_time class TestMessage: def test_text_message(self, group, friend): sent_message(group.send('text'), TEXT, group) msg = wait_for_message(group, TEXT) assert isinstance(msg, Message) assert msg.type == TEXT assert msg.text == 'Hello!' assert not msg.is_at assert msg.chat == group assert msg.sender == group assert msg.receiver == group.self assert msg.member == friend assert 0 < msg.latency < 30 group.send('at') msg = wait_for_message(group, TEXT) assert msg.is_at def test_picture_message(self, group, image_path): sent = group.send_image(image_path) sent_message(sent, PICTURE, group) assert sent.path == image_path def test_video_message(self, group, video_path): sent = group.send_video(video_path) sent_message(sent, VIDEO, group) assert sent.path == video_path def test_raw_message(self, group): # 发送名片 raw_type = 42 raw_content = ''.format('wxpy_bot', 'wxpy 机器人') sent_message(group.send_raw_msg(raw_type, raw_content), None, group) def test_send(self, friend, file_path, image_path, video_path): text_to_send = 'test sending text' sent = friend.send(text_to_send) sent_message(sent, TEXT, friend) assert sent.text == text_to_send sent = friend.send('@fil@{}'.format(file_path)) sent_message(sent, ATTACHMENT, friend) assert sent.path == file_path sent = friend.send('@img@{}'.format(image_path)) sent_message(sent, PICTURE, friend) assert sent.path == image_path sent = friend.send('@vid@{}'.format(video_path)) sent_message(sent, VIDEO, friend) assert sent.path == video_path # 发送名片 raw_type = 42 raw_content = ''.format('wxpy_bot', 'wxpy 机器人') uri = '/webwxsendmsg' sent = friend.send_raw_msg(raw_type, raw_content) sent_message(sent, None, friend) assert sent.type is None assert sent.raw_type == raw_type assert sent.raw_content == raw_content assert sent.uri == uri ================================================ FILE: tests/api/test_bot.py ================================================ import logging import os import time from wxpy import * class TestBot: def test_self(self, bot): assert bot.self.name is not None assert bot.self.name == bot.core.loginInfo['User']['NickName'] def test_repr(self, bot): assert repr(bot) == ''.format(bot.self.name) def test_alive(self, bot): assert bot.alive def test_dump_login_status(self, bot): bot.dump_login_status() updated_at = os.path.getmtime(bot.cache_path) assert time.time() - updated_at < 1 def test_enable_puid(self, bot, base_dir): from wxpy.utils.puid_map import PuidMap puid_path = os.path.join(base_dir, 'wxpy_bot_puid.pkl') puid_map = bot.enable_puid(puid_path) assert isinstance(puid_map, PuidMap) def test_chats(self, bot): chats = bot.chats() assert isinstance(chats, Chats) assert set(chats) == set(bot.friends() + bot.groups() + bot.mps()) def test_friends(self, bot): friends = bot.friends() assert isinstance(friends, Chats) assert bot.self in friends for friend in friends: assert isinstance(friend, Friend) def test_groups(self, bot): groups = bot.groups() assert isinstance(groups, Groups) for group in groups: assert isinstance(group, Group) assert bot.self in group def test_mps(self, bot): mps = bot.mps() assert isinstance(mps, Chats) for mp in mps: assert isinstance(mp, MP) def test_search(self, bot): found_1 = bot.search(bot.self.name, sex=bot.self.sex or None) assert bot.self in found_1 found_2 = bot.search(nick_name='__!#@$#%$__') assert not found_2 for found in found_1, found_2: assert isinstance(found, Chats) assert found.source == bot def test_create_group(self, bot): users = bot.friends()[:3] topic = 'test creating group' try: new_group = bot.create_group(users, topic) except ResponseError as e: logging.warning('Failed to create group: {}'.format(e)) except Exception as e: if 'Failed to create group:' in str(e): logging.warning(e) else: raise e else: assert new_group.name == topic assert new_group in bot.groups() assert set(users) == set(new_group.members) new_name = 'testing' new_group.rename_group(new_name) assert new_group.name == new_name def test_upload_file(self, bot, file_path, friend): media_id = bot.upload_file(file_path) friend.send_file(file_path, media_id=media_id) ================================================ FILE: tests/attachments/file.txt ================================================ Hello from wxpy! ================================================ FILE: tests/conftest.py ================================================ # coding: utf-8 """ 部分用例需要与 "wxpy 机器人" 进行互动 """ import os import time from functools import partial from queue import Queue import pytest from wxpy import * _base_dir = os.path.dirname(os.path.realpath(__file__)) print('logging in test bot...') _bot = Bot(os.path.join(_base_dir, 'wxpy_bot.pkl')) _friend = ensure_one(_bot.friends().search('wxpy 机器人')) _group = ensure_one(_bot.groups().search('wxpy test')) _member = ensure_one(_group.search('游否')) _shared_dict = dict() attachments_dir = os.path.join(_base_dir, 'attachments') gen_attachment_path = partial(os.path.join, attachments_dir) global_use = partial(pytest.fixture, scope='session', autouse=True) @global_use() def base_dir(): return _base_dir @global_use() def bot(): return _bot @global_use() def friend(): yield _friend while True: try: _friend.set_remark_name('') except ResponseError as e: if e.err_code == 1205: time.sleep(10) continue else: break @global_use() def group(): return _group @global_use() def shared_dict(): return _shared_dict @global_use() def member(): return _member @global_use() def image_path(): return gen_attachment_path('image.png') @global_use() def file_path(): return gen_attachment_path('file.txt') @global_use() def video_path(): return gen_attachment_path('video.mp4') def wait_for_message(chats=None, msg_types=None, except_self=True, timeout=30): """ 等待一条指定的消息,并返回这条消息 :param chats: 所需等待消息所在的聊天会话 :param msg_types: 所需等待的消息类型 :param except_self: 是否排除自己发送的消息 :param timeout: 等待的超时秒数,若为 None 则一直等待,直到收到所需的消息 :return: 若在超时内等到了消息,则返回此消息,否则抛出 `queue.Empty` 异常 """ received = Queue() @_bot.register(chats=chats, msg_types=msg_types, except_self=except_self) def _func(msg): received.put(msg) _config = _bot.registered.get_config_by_func(_func) ret = received.get(timeout=timeout) _bot.registered.remove(_config) return ret ================================================ FILE: tests/ext/__init__.py ================================================ ================================================ FILE: tests/utils/__init__.py ================================================ ================================================ FILE: wxpy/__compat__.py ================================================ ================================================ FILE: wxpy/__init__.py ================================================ #!/usr/bin/env python3 # coding: utf-8 """ 登陆微信:: # 导入模块 from wxpy import * # 初始化机器人,扫码登陆 bot = Bot() 找到好友:: # 搜索名称含有 "游否" 的男性深圳好友 my_friend = bot.friends().search('游否', sex=MALE, city="深圳")[0] 发送消息:: # 发送文本给好友 my_friend.send('Hello WeChat!') # 发送图片 my_friend.send_image('my_picture.jpg') 自动响应各类消息:: # 打印来自其他好友、群聊和公众号的消息 @bot.register() def print_others(msg): print(msg) # 回复 my_friend 的消息 (优先匹配后注册的函数!) @bot.register(my_friend) def reply_my_friend(msg): return 'received: {} ({})'.format(msg.text, msg.type) # 自动接受新的好友请求 @bot.register(msg_types=FRIENDS) def auto_accept_friends(msg): # 接受好友请求 new_friend = msg.card.accept() # 向新的好友发送消息 new_friend.send('哈哈,我自动接受了你的好友请求') 保持登陆/运行:: # 进入 Python 命令行、让程序保持运行 embed() # 或者仅仅堵塞线程 # bot.join() """ import logging import sys from .api.bot import Bot from .api.chats import Chat, Chats, Friend, Group, Groups, MP, Member, User 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 Article, Message, Messages, SentMessage from .exceptions import ResponseError from .ext import Tuling, WeChatLoggingHandler, XiaoI, get_wechat_logger, sync_message_in_groups from .utils import BaseRequest, detect_freq_limit, dont_raise_response_error, embed, ensure_one, mutual_friends __title__ = 'wxpy' __version__ = '0.3.9.8' __author__ = 'Youfou' __license__ = 'MIT' __copyright__ = '2017, Youfou' version_details = 'wxpy {ver} from {path} (python {pv.major}.{pv.minor}.{pv.micro})'.format( ver=__version__, path=__path__[0], pv=sys.version_info) try: # Python 2.7+ from logging import NullHandler except ImportError: class NullHandler(logging.Handler): def emit(self, record): pass logging.getLogger(__name__).addHandler(NullHandler()) ================================================ FILE: wxpy/__main__.py ================================================ # coding: utf-8 from .utils import shell_entry if __name__ == '__main__': shell_entry() ================================================ FILE: wxpy/api/__init__.py ================================================ ================================================ FILE: wxpy/api/bot.py ================================================ # coding: utf-8 from __future__ import unicode_literals import atexit import functools import logging import os.path import tempfile import time from pprint import pformat from threading import Thread try: import queue except ImportError: # noinspection PyUnresolvedReferences,PyPep8Naming import Queue as queue import itchat from ..api.chats import Chat, Chats, Friend, Group, MP, User from ..api.consts import SYSTEM from ..api.messages import Message, MessageConfig, Messages, Registered from ..compatible import PY2 from ..compatible.utils import force_encoded_string_output from ..utils import PuidMap from ..utils import enhance_connection, enhance_webwx_request, ensure_list, get_user_name, handle_response, \ start_new_thread, wrap_user_name logger = logging.getLogger(__name__) class Bot(object): """ 机器人对象,用于登陆和操作微信账号,涵盖大部分 Web 微信的功能:: from wxpy import * bot = Bot() # 机器人账号自身 myself = bot.self # 向文件传输助手发送消息 bot.file_helper.send('Hello from wxpy!') """ def __init__( self, cache_path=None, console_qr=False, qr_path=None, qr_callback=None, login_callback=None, logout_callback=None ): """ :param cache_path: * 设置当前会话的缓存路径,并开启缓存功能;为 `None` (默认) 则不开启缓存功能。 * 开启缓存后可在短时间内避免重复扫码,缓存失效时会重新要求登陆。 * 设为 `True` 时,使用默认的缓存路径 'wxpy.pkl'。 :param console_qr: * 在终端中显示登陆二维码,需要安装 pillow 模块 (`pip3 install pillow`)。 * 可为整数(int),表示二维码单元格的宽度,通常为 2 (当被设为 `True` 时,也将在内部当作 2)。 * 也可为负数,表示以反色显示二维码,适用于浅底深字的命令行界面。 * 例如: 在大部分 Linux 系统中可设为 `True` 或 2,而在 macOS Terminal 的默认白底配色中,应设为 -2。 :param qr_path: 保存二维码的路径 :param qr_callback: 获得二维码后的回调,可以用来定义二维码的处理方式,接收参数: uuid, status, qrcode :param login_callback: 登陆成功后的回调,若不指定,将进行清屏操作,并删除二维码文件 :param logout_callback: 登出时的回调 """ self.core = itchat.Core() itchat.instanceList.append(self) enhance_connection(self.core.s) if cache_path is True: cache_path = 'wxpy.pkl' self.cache_path = cache_path if console_qr is True: console_qr = 2 try: self.core.auto_login( hotReload=bool(cache_path), statusStorageDir=cache_path, enableCmdQR=console_qr, picDir=qr_path, qrCallback=qr_callback, loginCallback=login_callback, exitCallback=logout_callback ) except FileNotFoundError as e: if 'xdg-open' in e.strerror: raise Exception('use `console_qr` arg while under pure console environment') raise # 用于 "synccheck" 请求的 "_" 参数,每次请求时 + 1 self._sync_check_iterations = int(time.time() * 1000) enhance_webwx_request(self) self.self = User(self.core.loginInfo['User'], self) self.file_helper = Chat(wrap_user_name('filehelper'), self) self.messages = Messages() self.registered = Registered(self) self.puid_map = None self.auto_mark_as_read = False self.is_listening = False self.listening_thread = None if PY2: from wxpy.compatible.utils import TemporaryDirectory self.temp_dir = TemporaryDirectory(prefix='wxpy_') else: self.temp_dir = tempfile.TemporaryDirectory(prefix='wxpy_') self.start() atexit.register(self._cleanup) @force_encoded_string_output def __repr__(self): return '<{}: {}>'.format(self.__class__.__name__, self.self.name) def __unicode__(self): return '<{}: {}>'.format(self.__class__.__name__, self.self.name) @handle_response() def logout(self): """ 登出当前账号 """ logger.info('{}: logging out'.format(self)) return self.core.logout() @property def alive(self): """ 若为登陆状态,则为 True,否则为 False """ return self.core.alive @alive.setter def alive(self, value): self.core.alive = value def dump_login_status(self, cache_path=None): logger.debug('{}: dumping login status'.format(self)) return self.core.dump_login_status(cache_path or self.cache_path) # chats def enable_puid(self, path='wxpy_puid.pkl'): """ **可选操作:** 启用聊天对象的 :any:`puid ` 属性:: # 启用 puid 属性,并指定 puid 所需的映射数据保存/载入路径 bot.enable_puid('wxpy_puid.pkl') # 指定一个好友 my_friend = bot.friends().search('游否')[0] # 查看他的 puid print(my_friend.puid) # 'edfe8468' .. tip:: | :any:`puid ` 是 **wxpy 特有的聊天对象/用户ID** | 不同于其他 ID 属性,**puid** 可始终被获取到,且具有稳定的唯一性 :param path: puid 所需的映射数据保存/载入路径 """ self.puid_map = PuidMap(path) return self.puid_map def except_self(self, chats_or_dicts): """ 从聊天对象合集或用户字典列表中排除自身 :param chats_or_dicts: 聊天对象合集或用户字典列表 :return: 排除自身后的列表 :rtype: :class:`wxpy.Chats` """ return list(filter(lambda x: get_user_name(x) != self.self.user_name, chats_or_dicts)) # Todo: itchat 中更新本地聊天对象时,缺少线程锁,导致 AttributeError: 'NoneType' object has no attribute 'get' def chats(self, update=False): """ 获取所有聊天对象 :param update: 是否更新 :return: 聊天对象合集 :rtype: :class:`wxpy.Chats` """ return Chats(self.friends(update) + self.groups(update) + self.mps(update), self) def _retrieve_itchat_storage(self, attr): with self.core.storageClass.updateLock: return getattr(self.core.storageClass, attr) @handle_response(Friend) def friends(self, update=False): """ 获取所有好友 :param update: 是否更新 :return: 聊天对象合集 :rtype: :class:`wxpy.Chats` """ if update: logger.info('{}: updating friends'.format(self)) return self.core.get_friends(update=update) else: return self._retrieve_itchat_storage('memberList') @handle_response(Group) def groups(self, update=False, contact_only=False): """ 获取所有群聊对象 一些不活跃的群可能无法被获取到,可通过在群内发言,或修改群名称的方式来激活 :param update: 是否更新 :param contact_only: 是否限于保存为联系人的群聊 :return: 群聊合集 :rtype: :class:`wxpy.Groups` """ # itchat 原代码有些难懂,似乎 itchat 中的 get_contact() 所获取的内容视其 update 参数而变化 # 如果 update=False 获取所有类型的本地聊天对象 # 反之如果 update=True,变为获取收藏的聊天室 if update or contact_only: logger.info('{}: updating groups'.format(self)) return self.core.get_chatrooms(update=update, contactOnly=contact_only) else: return self._retrieve_itchat_storage('chatroomList') @handle_response(MP) def mps(self, update=False): """ 获取所有公众号 :param update: 是否更新 :return: 聊天对象合集 :rtype: :class:`wxpy.Chats` """ if update: logger.info('{}: updating mps'.format(self)) return self.core.get_mps(update=update) else: return self._retrieve_itchat_storage('mpList') @handle_response(User) def user_details(self, user_or_users, chunk_size=50): """ 获取单个或批量获取多个用户的详细信息(地区、性别、签名等),但不可用于群聊成员 :param user_or_users: 单个或多个用户对象或 user_name :param chunk_size: 分配请求时的单批数量,目前为 50 :return: 单个或多个用户用户的详细信息 """ def chunks(): total = ensure_list(user_or_users) for i in range(0, len(total), chunk_size): yield total[i:i + chunk_size] @handle_response() def process_one_chunk(_chunk): return self.core.update_friend(userName=get_user_name(_chunk)) if isinstance(user_or_users, (list, tuple)): ret = list() for chunk in chunks(): chunk_ret = process_one_chunk(chunk) if isinstance(chunk_ret, list): ret += chunk_ret else: ret.append(chunk_ret) return ret else: return process_one_chunk(user_or_users) def search(self, keywords=None, **attributes): """ 在所有类型的聊天对象中进行搜索 .. note:: | 搜索结果为一个 :class:`Chats (列表) ` 对象 | 建议搭配 :any:`ensure_one()` 使用 :param keywords: 聊天对象的名称关键词 :param attributes: 属性键值对,键可以是 sex(性别), province(省份), city(城市) 等。例如可指定 province='广东' :return: 匹配的聊天对象合集 :rtype: :class:`wxpy.Chats` """ return self.chats().search(keywords, **attributes) # add / create @handle_response() def add_friend(self, user, verify_content=''): """ 添加用户为好友 :param user: 用户对象,或 user_name :param verify_content: 验证说明信息 """ logger.info('{}: adding {} (verify_content: {})'.format(self, user, verify_content)) user_name = get_user_name(user) return self.core.add_friend( userName=user_name, status=2, verifyContent=verify_content, autoUpdate=True ) @handle_response() def add_mp(self, user): """ 添加/关注 公众号 :param user: 公众号对象,或 user_name """ 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=''): """ 接受用户为好友 :param user: 用户对象或 user_name :param verify_content: 验证说明信息 :return: 新的好友对象 :rtype: :class:`wxpy.Friend` """ logger.info('{}: accepting {} (verify_content: {})'.format(self, user, verify_content)) @handle_response() def do(): return self.core.add_friend( userName=get_user_name(user), status=3, verifyContent=verify_content, autoUpdate=True ) do() # 若上一步没有抛出异常,则返回该好友 for friend in self.friends(): if friend == user: return friend def create_group(self, users, topic=None): """ 创建一个新的群聊 :param users: 用户列表 (不含自己,至少 2 位) :param topic: 群名称 :return: 若建群成功,返回一个新的群聊对象 :rtype: :class:`wxpy.Group` """ logger.info('{}: creating group (topic: {}), with users:\n{}'.format( self, topic, pformat(users))) @handle_response() def request(): return self.core.create_chatroom( memberList=dict_list, topic=topic or '' ) dict_list = wrap_user_name(self.except_self(ensure_list(users))) ret = request() user_name = ret.get('ChatRoomName') if user_name: return Group(self.core.update_chatroom(userName=user_name), self) else: from wxpy.utils import decode_text_from_webwx ret = decode_text_from_webwx(pformat(ret)) raise Exception('Failed to create group:\n{}'.format(ret)) # upload def upload_file(self, path): """ | 上传文件,并获取 media_id | 可用于重复发送图片、表情、视频,和文件 :param path: 文件路径 :return: media_id :rtype: str """ logger.info('{}: uploading file: {}'.format(self, path)) @handle_response() def do(): upload = functools.partial(self.core.upload_file, fileDir=path) ext = os.path.splitext(path)[1].lower() if ext in ('.bmp', '.png', '.jpeg', '.jpg', '.gif'): return upload(isPicture=True) elif ext == '.mp4': return upload(isVideo=True) else: return upload() return do().get('MediaId') # messages / register def _process_message(self, msg): """ 处理接收到的消息 """ if not self.alive: return config = self.registered.get_config(msg) logger.debug('{}: new message (func: {}):\n{}'.format( self, config.func.__name__ if config else None, msg)) if config: def process(): # noinspection PyBroadException try: ret = config.func(msg) if ret is not None: msg.reply(ret) except: logger.exception('an error occurred in {}.'.format(config.func)) if self.auto_mark_as_read and not msg.type == SYSTEM and msg.sender != self.self: from wxpy import ResponseError try: msg.chat.mark_as_read() except ResponseError as e: logger.warning('failed to mark as read: {}'.format(e)) if config.run_async: start_new_thread(process, use_caller_name=True) else: process() def register( self, chats=None, msg_types=None, except_self=True, run_async=True, enabled=True ): """ 装饰器:用于注册消息配置 :param chats: 消息所在的聊天对象:单个或列表形式的多个聊天对象或聊天类型,为空时匹配所有聊天对象 :param msg_types: 消息的类型:单个或列表形式的多个消息类型,为空时匹配所有消息类型 (SYSTEM 类消息除外) :param except_self: 排除由自己发送的消息 :param run_async: 是否异步执行所配置的函数:可提高响应速度 :param enabled: 当前配置的默认开启状态,可事后动态开启或关闭 """ def do_register(func): self.registered.append(MessageConfig( bot=self, func=func, chats=chats, msg_types=msg_types, except_self=except_self, run_async=run_async, enabled=enabled )) return func return do_register def _listen(self): # Todo: 在短时间内收到多条消息时,会偶尔漏收消息(Web 微信没有问题) try: logger.info('{}: started'.format(self)) self.is_listening = True while self.alive and self.is_listening: try: msg = Message(self.core.msgList.get(timeout=0.5), self) except queue.Empty: continue if msg.type != SYSTEM: self.messages.append(msg) # noinspection PyBroadException try: self._process_message(msg) except: logger.exception('an error occurred while processing msg:\n{}'.format(msg)) finally: self.is_listening = False logger.info('{}: stopped'.format(self)) def start(self): """ 开始消息监听和处理 (登陆后会自动开始) """ if not self.alive: logger.warning('{} has been logged out!'.format(self)) elif self.is_listening: logger.warning('{} is already running, no need to start again.'.format(self)) else: self.listening_thread = start_new_thread(self._listen) def stop(self): """ 停止消息监听和处理 (登出后会自动停止) """ if self.is_listening: self.is_listening = False self.listening_thread.join() else: logger.warning('{} is not running.'.format(self)) def join(self): """ 堵塞进程,直到结束消息监听 (例如,机器人被登出时) """ if isinstance(self.listening_thread, Thread): try: logger.info('{}: joined'.format(self)) self.listening_thread.join() except KeyboardInterrupt: pass def _cleanup(self): if self.is_listening: self.stop() if self.alive and self.core.useHotReload: self.dump_login_status() self.temp_dir.cleanup() ================================================ FILE: wxpy/api/chats/__init__.py ================================================ from .chat import Chat from .chats import Chats from .friend import Friend from .group import Group from .groups import Groups from .member import Member from .mp import MP from .user import User ================================================ FILE: wxpy/api/chats/chat.py ================================================ # coding: utf-8 from __future__ import unicode_literals import datetime import logging import re import time from functools import partial, wraps from wxpy.api.consts import ATTACHMENT, PICTURE, TEXT, VIDEO from wxpy.compatible import * from wxpy.compatible.utils import force_encoded_string_output from wxpy.utils import handle_response logger = logging.getLogger(__name__) def wrapped_send(msg_type): """ send() 系列方法较为雷同,因此采用装饰器方式完成发送,并返回 SentMessage 对象 """ def decorator(func): @wraps(func) def wrapped(self, *args, **kwargs): # 用于初始化 SentMessage 的属性 sent_attrs = dict( type=msg_type, receiver=self, create_time=datetime.datetime.now() ) # 被装饰函数需要返回两个部分: # itchat_call_or_ret: 请求 itchat 原函数的参数字典 (或返回值字典) # sent_attrs_from_method: 方法中需要添加到 SentMessage 的属性字典 itchat_call_or_ret, sent_attrs_from_method = func(self, *args, **kwargs) if msg_type: # 找到原 itchat 中的同名函数,并转化为指定了 `toUserName` 的偏函数 itchat_partial_func = partial( getattr(self.bot.core, func.__name__), toUserName=self.user_name ) logger.info('sending {} to {}:\n{}'.format( func.__name__[5:], self, sent_attrs_from_method.get('text') or sent_attrs_from_method.get('path') )) @handle_response() def do_send(): return itchat_partial_func(**itchat_call_or_ret) ret = do_send() else: # send_raw_msg 会直接返回结果 ret = itchat_call_or_ret sent_attrs['receive_time'] = datetime.datetime.now() try: sent_attrs['id'] = int(ret.get('MsgID')) except (ValueError, TypeError): pass sent_attrs['local_id'] = ret.get('LocalID') # 加入被装饰函数返回值中的属性字典 sent_attrs.update(sent_attrs_from_method) from wxpy import SentMessage sent = SentMessage(attributes=sent_attrs) self.bot.messages.append(sent) return sent return wrapped return decorator class Chat(object): """ 单个用户 (:class:`User`) 和群聊 (:class:`Group`) 的基础类 """ def __init__(self, raw, bot): self.raw = raw self.bot = bot @property def puid(self): """ 持续有效,且稳定唯一的聊天对象/用户ID,适用于持久保存 请使用 :any:`Bot.enable_puid()` 来启用 puid 属性 .. tip:: | :any:`puid ` 是 **wxpy 特有的聊天对象/用户ID** | 不同于其他 ID 属性,**puid** 可始终被获取到,且具有稳定的唯一性 .. attention:: puid 映射数据 **不可跨机器人使用** """ if self.bot.puid_map: return self.bot.puid_map.get_puid(self) else: raise TypeError('puid is not enabled, you can enable it by `bot.enable_puid()`') @property def nick_name(self): """ 该聊天对象的昵称 (好友、群员的昵称,或群名称) """ if self.user_name == 'filehelper': return '文件传输助手' elif self.user_name == 'fmessage': return '好友请求' else: return self.raw.get('NickName') @property def name(self): """ | 该聊天对象的友好名称 | 具体为: 从 备注名称、群聊显示名称、昵称(或群名称),或微信号中 | 按序选取第一个可用的 """ for attr in 'remark_name', 'display_name', 'nick_name', 'wxid': _name = getattr(self, attr, None) if _name: return _name def send(self, content=None, media_id=None): """ 动态发送不同类型的消息,具体类型取决于 `msg` 的前缀。 :param content: * 由 **前缀** 和 **内容** 两个部分组成,若 **省略前缀**,将作为纯文本消息发送 * **前缀** 部分可为: '@fil@', '@img@', '@msg@', '@vid@' (不含引号) * 分别表示: 文件,图片,纯文本,视频 * **内容** 部分可为: 文件、图片、视频的路径,或纯文本的内容 :param media_id: 填写后可省略上传过程 :rtype: :class:`wxpy.SentMessage` """ method_map = dict(fil=self.send_file, img=self.send_image, vid=self.send_video) content = str('' if content is None else content) try: method, content = re.match(r'@(\w{3})@(.+)', content).groups() except AttributeError: method = None if method: return method_map[method](path=content, media_id=media_id) else: return self.send_msg(msg=content) @wrapped_send(TEXT) def send_msg(self, msg=None): """ 发送文本消息 :param msg: 文本内容 :rtype: :class:`wxpy.SentMessage` """ if msg is None: msg = 'Hello from wxpy!' else: msg = str(msg) return dict(msg=msg), dict(text=msg) # Todo: 发送后可获取到 media_id @wrapped_send(PICTURE) def send_image(self, path, media_id=None): """ 发送图片 :param path: 文件路径 :param media_id: 设置后可省略上传 :rtype: :class:`wxpy.SentMessage` """ return dict(fileDir=path, mediaId=media_id), locals() @wrapped_send(ATTACHMENT) def send_file(self, path, media_id=None): """ 发送文件 :param path: 文件路径 :param media_id: 设置后可省略上传 :rtype: :class:`wxpy.SentMessage` """ return dict(fileDir=path, mediaId=media_id), locals() @wrapped_send(VIDEO) def send_video(self, path=None, media_id=None): """ 发送视频 :param path: 文件路径 :param media_id: 设置后可省略上传 :rtype: :class:`wxpy.SentMessage` """ return dict(fileDir=path, mediaId=media_id), locals() @wrapped_send(None) def send_raw_msg(self, raw_type, raw_content, uri=None, msg_ext=None): """ 以原始格式发送其他类型的消息。 :param int raw_type: 原始的整数消息类型 :param str raw_content: 原始的消息内容 :param str uri: 请求路径,默认为 '/webwxsendmsg' :param dict msg_ext: 消息的扩展属性 (会被更新到 `Msg` 键中) :rtype: :class:`wxpy.SentMessage` 例如,发送好友或公众号的名片:: my_friend.send_raw_msg( # 名片的原始消息类型 raw_type=42, # 注意 `username` 在这里应为微信 ID,且被发送的名片必须为自己的好友 raw_content='' ) """ logger.info('sending raw msg to {}'.format(self)) uri = uri or '/webwxsendmsg' from wxpy.utils import BaseRequest req = BaseRequest(self.bot, uri=uri) msg = { 'Type': raw_type, 'Content': raw_content, 'FromUserName': self.bot.self.user_name, 'ToUserName': self.user_name, 'LocalID': int(time.time() * 1e4), 'ClientMsgId': int(time.time() * 1e4), } if msg_ext: msg.update(msg_ext) req.data.update({'Msg': msg, 'Scene': 0}) # noinspection PyUnresolvedReferences return req.post(), { 'raw_type': raw_type, 'raw_content': raw_content, 'uri': uri, 'msg_ext': msg_ext, } @handle_response() def mark_as_read(self): """ 消除当前聊天对象的未读提示小红点 """ from wxpy.utils import BaseRequest req = BaseRequest( bot=self.bot, # itchat 中的 pass_ticket 已经预先编码了 uri='/webwxstatusnotify?pass_ticket={}'.format(self.bot.core.loginInfo['pass_ticket']) ) req.data.update({ 'ClientMsgId': int(time.time() * 1000), 'Code': 1, 'FromUserName': self.bot.self.user_name, 'ToUserName': self.user_name, }) logger.debug('marking {} as read'.format(self)) return req.request('POST') @handle_response() def pin(self): """ 将聊天对象置顶 """ logger.info('pinning {}'.format(self)) return self.bot.core.set_pinned(userName=self.user_name, isPinned=True) @handle_response() def unpin(self): """ 取消聊天对象的置顶状态 """ logger.info('unpinning {}'.format(self)) return self.bot.core.set_pinned(userName=self.user_name, isPinned=False) @handle_response() def get_avatar(self, save_path=None): """ 获取头像 :param save_path: 保存路径(后缀通常为.jpg),若为 `None` 则返回字节数据 """ logger.info('getting avatar of {}'.format(self)) from .group import Group from .member import Member from .friend import User if isinstance(self, Group): kwargs = dict(userName=None, chatroomUserName=self.user_name) elif isinstance(self, Member): kwargs = dict(userName=self.user_name, chatroomUserName=self.group.user_name) elif isinstance(self, User): kwargs = dict(userName=self.user_name, chatroomUserName=None) else: raise TypeError('expected `Chat`, got`{}`'.format(type(self))) kwargs.update(dict(picDir=save_path)) return self.bot.core.get_head_img(**kwargs) @property def uin(self): """ 微信中的聊天对象ID,固定且唯一 | 因微信的隐私策略,该属性有时无法被获取到 | 建议使用 :any:`puid ` 作为用户的唯一 ID """ return self.raw.get('Uin') @property def alias(self): """ 若用户进行过一次性的 "设置微信号" 操作,则该值为用户设置的"微信号",固定且唯一 | 因微信的隐私策略,该属性有时无法被获取到 | 建议使用 :any:`puid ` 作为用户的唯一 ID """ return self.raw.get('Alias') @property def wxid(self): """ 聊天对象的微信ID (实际为 .alias 或 .uin) | 因微信的隐私策略,该属性有时无法被获取到 | 建议使用 :any:`puid ` 作为用户的唯一 ID """ return self.alias or self.uin or None @property def user_name(self): """ 该聊天对象的内部 ID,通常不需要用到 .. attention:: 同个聊天对象在不同用户中,此 ID **不一致** ,且可能在新会话中 **被改变**! """ return self.raw.get('UserName') @force_encoded_string_output def __repr__(self): return '<{}: {}>'.format(self.__class__.__name__, self.name) def __unicode__(self): return '<{}: {}>'.format(self.__class__.__name__, self.name) def __eq__(self, other): return hash(self) == hash(other) def __cmp__(self, other): if hash(self) == hash(other): return 0 return 1 def __hash__(self): return hash((Chat, self.user_name)) ================================================ FILE: wxpy/api/chats/chats.py ================================================ # coding: utf-8 from __future__ import unicode_literals import logging import time from collections import Counter from wxpy.utils import match_attributes, match_name from wxpy.compatible import * logger = logging.getLogger(__name__) class Chats(list): """ 多个聊天对象的合集,可用于搜索或统计 """ def __init__(self, chat_list=None, source=None): if chat_list: super(Chats, self).__init__(chat_list) self.source = source def __add__(self, other): return Chats(super(Chats, self).__add__(other or list())) def search(self, keywords=None, **attributes): """ 在聊天对象合集中进行搜索 .. note:: | 搜索结果为一个 :class:`Chats (列表) ` 对象 | 建议搭配 :any:`ensure_one()` 使用 :param keywords: 聊天对象的名称关键词 :param attributes: 属性键值对,键可以是 sex(性别), province(省份), city(城市) 等。例如可指定 province='广东' :return: 匹配的聊天对象合集 :rtype: :class:`wxpy.Chats` """ def match(chat): if not match_name(chat, keywords): return if not match_attributes(chat, **attributes): return return True return Chats(filter(match, self), self.source) def stats(self, attribs=('sex', 'province', 'city')): """ 统计各属性的分布情况 :param attribs: 需统计的属性列表或元组 :return: 统计结果 """ def attr_stat(objects, attr_name): return Counter(list(map(lambda x: getattr(x, attr_name), objects))) from wxpy.utils import ensure_list attribs = ensure_list(attribs) ret = dict() for attr in attribs: ret[attr] = attr_stat(self, attr) return ret def stats_text(self, total=True, sex=True, top_provinces=10, top_cities=10): """ 简单的统计结果的文本 :param total: 总体数量 :param sex: 性别分布 :param top_provinces: 省份分布 :param top_cities: 城市分布 :return: 统计结果文本 """ from .group import Group from wxpy.api.consts import FEMALE from wxpy.api.consts import MALE from wxpy.api.bot import Bot def top_n_text(attr, n): top_n = list(filter(lambda x: x[0], stats[attr].most_common()))[:n] top_n = ['{}: {} ({:.2%})'.format(k, v, v / len(self)) for k, v in top_n] return '\n'.join(top_n) stats = self.stats() text = str() if total: if self.source: if isinstance(self.source, Bot): user_title = '微信好友' nick_name = self.source.self.nick_name elif isinstance(self.source, Group): user_title = '群成员' nick_name = self.source.nick_name else: raise TypeError('source should be Bot or Group') text += '{nick_name} 共有 {total} 位{user_title}\n\n'.format( nick_name=nick_name, total=len(self), user_title=user_title ) else: text += '共有 {} 位用户\n\n'.format(len(self)) if sex and self: males = stats['sex'].get(MALE, 0) females = stats['sex'].get(FEMALE, 0) text += '男性: {males} ({male_rate:.1%})\n女性: {females} ({female_rate:.1%})\n\n'.format( males=males, male_rate=males / len(self), females=females, female_rate=females / len(self), ) if top_provinces and self: text += 'TOP {} 省份\n{}\n\n'.format( top_provinces, top_n_text('province', top_provinces) ) if top_cities and self: text += 'TOP {} 城市\n{}\n\n'.format( top_cities, top_n_text('city', top_cities) ) return text def add_all(self, interval=3, verify_content=''): """ 将合集中的所有用户加为好友,请小心应对调用频率限制! :param interval: 间隔时间(秒) :param verify_content: 验证说明文本 """ to_add = self[:] while to_add: adding = to_add.pop(0) logger.info('Adding {}'.format(adding)) ret = adding.add(verify_content=verify_content) logger.info(ret) logger.info('Waiting for {} seconds'.format(interval)) if to_add: time.sleep(interval) ================================================ FILE: wxpy/api/chats/friend.py ================================================ # coding: utf-8 from __future__ import unicode_literals import logging from .user import User logger = logging.getLogger(__name__) class Friend(User): """ 好友对象 """ pass ================================================ FILE: wxpy/api/chats/group.py ================================================ # coding: utf-8 from __future__ import unicode_literals import logging from wxpy.utils import ensure_list, get_user_name, handle_response, wrap_user_name from .chat import Chat from .chats import Chats from .member import Member logger = logging.getLogger(__name__) class Group(Chat): """ 群聊对象 """ def __init__(self, raw, bot): super(Group, self).__init__(raw, bot) @property def members(self): """ 群聊的成员列表 """ def raw_member_list(update=False): if update: self.update_group() return self.raw.get('MemberList', list()) ret = Chats(source=self) ret.extend(map( lambda x: Member(x, self), raw_member_list() or raw_member_list(True) )) return ret def __contains__(self, user): user_name = get_user_name(user) for member in self.members: if member.user_name == user_name: return member def __iter__(self): for member in self.members: yield member def __len__(self): return len(self.members) def search(self, keywords=None, **attributes): """ 在群聊中搜索成员 .. note:: | 搜索结果为一个 :class:`Chats (列表) ` 对象 | 建议搭配 :any:`ensure_one()` 使用 :param keywords: 成员名称关键词 :param attributes: 属性键值对 :return: 匹配的群聊成员 :rtype: :class:`wxpy.Chats` """ return self.members.search(keywords, **attributes) @property def owner(self): """ 返回群主对象 """ owner_user_name = self.raw.get('ChatRoomOwner') if owner_user_name: for member in self: if member.user_name == owner_user_name: return member elif self.members: return self.members[0] @property def is_owner(self): """ 判断所属 bot 是否为群管理员 """ return self.raw.get('IsOwner') == 1 or self.owner == self.bot.self @property def self(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): """ 更新群聊的信息 :param members_details: 是否包括群聊成员的详细信息 (地区、性别、签名等) """ logger.info('updating {} (members_details={})'.format(self, members_details)) @handle_response() def do(): return self.bot.core.update_chatroom(self.user_name, members_details) super(Group, self).__init__(do(), self.bot) @handle_response() def add_members(self, users, use_invitation=False): """ 向群聊中加入用户 :param users: 待加入的用户列表或单个用户 :param use_invitation: 使用发送邀请的方式 """ logger.info('adding {} into {} (use_invitation={}))'.format(users, self, use_invitation)) return self.bot.core.add_member_into_chatroom( self.user_name, ensure_list(wrap_user_name(users)), use_invitation ) @handle_response() def remove_members(self, members): """ 从群聊中移除用户 :param members: 待移除的用户列表或单个用户 """ logger.info('removing {} from {}'.format(members, self)) return self.bot.core.delete_member_from_chatroom( self.user_name, ensure_list(wrap_user_name(members)) ) def rename_group(self, name): """ 修改群聊名称 :param name: 新的名称,超长部分会被截断 (最长32字节) """ encodings = ('gbk', 'utf-8') trimmed = False for ecd in encodings: for length in range(32, 24, -1): try: name = bytes(name.encode(ecd))[:length].decode(ecd) except (UnicodeEncodeError, UnicodeDecodeError): continue else: trimmed = True break if trimmed: break @handle_response() def do(): logger.info('renaming group: {} => {}'.format(self.name, name)) return self.bot.core.set_chatroom_name(get_user_name(self), name) ret = do() self.update_group() return ret ================================================ FILE: wxpy/api/chats/groups.py ================================================ # coding: utf-8 from __future__ import unicode_literals from wxpy.utils import ensure_list, match_attributes, match_name from .user import User class Groups(list): """ 群聊的合集,可用于按条件搜索 """ # 记录已知的 shadow group 和 valid group # shadow group 直接抛弃 # valid group 直接通过 # 其他的需要确认是否包含机器人自身,并再分类到上面两种群中 shadow_group_user_names = list() valid_group_user_names = list() def __init__(self, group_list=None): if group_list: # Web 微信服务端似乎有个 BUG,会返回不存在的群 # 具体表现为: 名称为先前退出的群,但成员列表却完全陌生 # 因此加一个保护逻辑: 只返回"包含自己的群" groups_to_init = list() for group in group_list: if group.user_name in Groups.shadow_group_user_names: continue elif group.user_name in Groups.valid_group_user_names: groups_to_init.append(group) else: if group.bot.self in group: Groups.valid_group_user_names.append(group.user_name) groups_to_init.append(group) else: Groups.shadow_group_user_names.append(group.user_name) super(Groups, self).__init__(groups_to_init) def search(self, keywords=None, users=None, **attributes): """ 在群聊合集中,根据给定的条件进行搜索 :param keywords: 群聊名称关键词 :param users: 需包含的用户 :param attributes: 属性键值对,键可以是 owner(群主对象), is_owner(自身是否为群主), nick_name(精准名称) 等。 :return: 匹配条件的群聊列表 :rtype: :class:`wxpy.Groups` """ users = ensure_list(users) if users: for user in users: if not isinstance(user, User): raise TypeError('expected `User`, got {} (type: {})'.format(user, type(user))) def match(group): if not match_name(group, keywords): return if users: for _user in users: if _user not in group: return if not match_attributes(group, **attributes): return return True return Groups(filter(match, self)) ================================================ FILE: wxpy/api/chats/member.py ================================================ # coding: utf-8 from __future__ import unicode_literals from .user import User # Todo: 若尝试获取群成员信息时为空,自动更新成员信息 (并要照顾到遍历所有群成员的场景) class Member(User): """ 群聊成员对象 """ def __init__(self, raw, group): super(Member, self).__init__(raw, group.bot) self._group_user_name = group.user_name @property def group(self): for _group in self.bot.groups(): if _group.user_name == self._group_user_name: return _group raise Exception('failed to find the group belong to') @property def display_name(self): """ 在群聊中的显示昵称 """ return self.raw.get('DisplayName') def remove(self): """ 从群聊中移除该成员 """ return self.group.remove_members(self) @property def name(self): """ | 该群成员的友好名称 | 具体为: 从 群聊显示名称、昵称(或群名称),或微信号中,按序选取第一个可用的 """ for attr in 'display_name', 'nick_name', 'wxid': _name = getattr(self, attr, None) if _name: return _name ================================================ FILE: wxpy/api/chats/mp.py ================================================ # coding: utf-8 from .user import User class MP(User): """ 公众号对象 """ pass ================================================ FILE: wxpy/api/chats/user.py ================================================ # coding: utf-8 from __future__ import unicode_literals import logging from wxpy.utils import handle_response from .chat import Chat logger = logging.getLogger(__name__) class User(Chat): """ 好友(:class:`Friend`)、群聊成员(:class:`Member`),和公众号(:class:`MP`) 的基础类 """ def __init__(self, raw, bot): super(User, self).__init__(raw, bot) @property def remark_name(self): """ 备注名称 """ return self.raw.get('RemarkName') @handle_response() def set_remark_name(self, remark_name): """ 设置或修改好友的备注名称 :param remark_name: 新的备注名称 """ logger.info('setting remark name for {}: {}'.format(self, remark_name)) return self.bot.core.set_alias(userName=self.user_name, alias=remark_name) @property def sex(self): """ 性别,目前有:: # 男性 MALE = 1 # 女性 FEMALE = 2 未设置时为 `None` """ return self.raw.get('Sex') @property def province(self): """ 省份 """ return self.raw.get('Province') @property def city(self): """ 城市 """ return self.raw.get('City') @property def signature(self): """ 个性签名 """ return self.raw.get('Signature') @property def is_friend(self): """ 判断当前用户是否为好友关系 :return: 若为好友关系,返回对应的好友,否则返回 False """ if self.bot: try: friends = self.bot.friends() index = friends.index(self) return friends[index] except ValueError: return False def add(self, verify_content=''): """ 把当前用户加为好友 :param verify_content: 验证信息(文本) """ return self.bot.add_friend(user=self, verify_content=verify_content) def accept(self, verify_content=''): """ 接受当前用户为好友 :param verify_content: 验证信息(文本) :return: 新的好友对象 :rtype: :class:`wxpy.Friend` """ return self.bot.accept_friend(user=self, verify_content=verify_content) ================================================ FILE: wxpy/api/consts.py ================================================ # coding: utf-8 from __future__ import unicode_literals # 文本 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 ================================================ FILE: wxpy/api/messages/__init__.py ================================================ from .article import Article from .message import Message from .message_config import MessageConfig from .messages import Messages from .registered import Registered from .sent_message import SentMessage ================================================ FILE: wxpy/api/messages/article.py ================================================ # coding: utf-8 from __future__ import unicode_literals from wxpy.compatible.utils import force_encoded_string_output class Article(object): def __init__(self): """ 公众号推送中的单篇文章内容 (一次可推送多篇) """ # 标题 self.title = None # 摘要 self.summary = None # 文章 URL self.url = None # 封面图片 URL self.cover = None @force_encoded_string_output def __repr__(self): return self.__unicode__() def __unicode__(self): return '<{}: {}>'.format(self.__class__.__name__, self.title) def __hash__(self): return hash((Article, self.url)) def __eq__(self, other): return hash(self) == hash(other) def __cmp__(self, other): if hash(self) == hash(other): return 0 return 1 ================================================ FILE: wxpy/api/messages/message.py ================================================ # coding: utf-8 from __future__ import unicode_literals import logging import os import tempfile import weakref from datetime import datetime from xml.etree import ElementTree as ETree try: import html except ImportError: # Python 2.6-2.7 # noinspection PyUnresolvedReferences,PyUnresolvedReferences,PyCompatibility from HTMLParser import HTMLParser html = HTMLParser() from wxpy.api.chats import Chat, Group, Member, User from wxpy.compatible.utils import force_encoded_string_output from wxpy.utils import wrap_user_name, repr_message from .article import Article from ..consts import ATTACHMENT, CARD, FRIENDS, MAP, PICTURE, RECORDING, SHARING, TEXT, VIDEO from ...compatible import * logger = logging.getLogger(__name__) class Message(object): """ 单条消息对象,包括: * 来自好友、群聊、好友请求等聊天对象的消息 * 使用机器人账号在手机微信中发送的消息 | 但 **不包括** 代码中通过 .send/reply() 系列方法发出的消息 | 此类消息请参见 :class:`SentMessage` """ def __init__(self, raw, bot): self.raw = raw self.bot = weakref.proxy(bot) 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': setattr(self, 'reply' + method, getattr(self.chat, 'send' + method)) def __hash__(self): return hash((Message, self.id)) @force_encoded_string_output def __repr__(self): return repr_message(self) def __unicode__(self): return repr_message(self) # 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' :rtype: str """ return self.raw.get('Type') @property def id(self): """ 消息的唯一 ID (通常为大于 0 的 64 位整型) """ return self.raw.get('NewMsgId') # content @property def text(self): """ 消息的文本内容 """ _type = self.type _card = self.card if _type == MAP: location = self.location if location: return location.get('label') elif _card: if _type == CARD: return _card.name elif _type == FRIENDS: return _card.raw.get('Content') ret = self.raw.get('Text') if isinstance(ret, str): return ret def get_file(self, save_path=None): """ 下载图片、视频、语音、附件消息中的文件内容。 可与 :any:`Message.file_name` 配合使用。 :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): """ 文件类消息中的文件资源 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 """ _url = self.raw.get('Url') if isinstance(_url, str): _url = html.unescape(_url) return _url @property def articles(self): """ 公众号推送中的文章列表 (首篇的 标题/地址 与消息中的 text/url 相同) 其中,每篇文章均有以下属性: * `title`: 标题 * `summary`: 摘要 * `url`: 文章 URL * `cover`: 封面或缩略图 URL """ from wxpy import MP if self.type == SHARING and isinstance(self.sender, MP): tree = ETree.fromstring(self.raw['Content']) # noinspection SpellCheckingInspection items = tree.findall('.//mmreader/category/item') article_list = list() for item in items: def find_text(tag): found = item.find(tag) if found is not None: return found.text article = Article() article.title = find_text('title') article.summary = find_text('digest') article.url = find_text('url') article.cover = find_text('cover') article_list.append(article) return article_list @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): """ 消息所在的聊天会话,即: * 对于自己发送的消息,为消息的接收者 * 对于别人发送的消息,为消息的发送者 :rtype: :class:`wxpy.User`, :class:`wxpy.Group` """ if self.raw.get('FromUserName') == self.bot.self.user_name: return self.receiver else: return self.sender @property def sender(self): """ 消息的发送者 :rtype: :class:`wxpy.User`, :class:`wxpy.Group` """ return self._get_chat_by_user_name(self.raw.get('FromUserName')) @property def receiver(self): """ 消息的接收者 :rtype: :class:`wxpy.User`, :class:`wxpy.Group` """ return self._get_chat_by_user_name(self.raw.get('ToUserName')) @property def member(self): """ * 若消息来自群聊,则此属性为消息的实际发送人(具体的群成员) * 若消息来自其他聊天对象(非群聊),则此属性为 None :rtype: NoneType, :class:`wxpy.Member` """ if isinstance(self.chat, Group): if self.sender == self.bot.self: return self.chat.self else: actual_user_name = self.raw.get('ActualUserName') for _member in self.chat.members: if _member.user_name == actual_user_name: return _member return Member(dict( UserName=actual_user_name, NickName=self.raw.get('ActualNickName') ), self.chat) def _get_chat_by_user_name(self, user_name): """ 通过 user_name 找到对应的聊天对象 :param user_name: user_name :return: 找到的对应聊天对象 """ def match_in_chats(_chats): for c in _chats: if c.user_name == user_name: return c _chat = None if user_name.startswith('@@'): _chat = match_in_chats(self.bot.groups()) elif user_name: _chat = match_in_chats(self.bot.friends()) if not _chat: _chat = match_in_chats(self.bot.mps()) if not _chat: _chat = Chat(wrap_user_name(user_name), self.bot) return _chat def forward(self, chat, prefix=None, suffix=None, raise_for_unsupported=False): """ 将本消息转发给其他聊天对象 支持以下消息类型 * 文本 (`TEXT`) * 视频(`VIDEO`) * 文件 (`ATTACHMENT`) * 图片/自定义表情 (`PICTURE`) * 但不支持表情商店中的表情 * 名片 (`CARD`) * 仅支持公众号名片,以及自己发出的个人号名片 * 分享 (`SHARING`) * 会转化为 `标题 + 链接` 形式的文本消息 * 语音 (`RECORDING`) * 会以文件方式发送 * 地图 (`MAP`) * 会转化为 `位置名称 + 地图链接` 形式的文本消息 :param Chat chat: 接收转发消息的聊天对象 :param str prefix: 转发时增加的 **前缀** 文本,原消息为文本时会自动换行 :param str suffix: 转发时增加的 **后缀** 文本,原消息为文本时会自动换行 :param bool raise_for_unsupported: | 为 True 时,将为不支持的消息类型抛出 `NotImplementedError` 异常 例如,将公司群中的老板消息转发出来:: from wxpy import * bot = Bot() # 定位公司群 company_group = ensure_one(bot.groups().search('公司微信群')) # 定位老板 boss = ensure_one(company_group.search('老板大名')) # 将老板的消息转发到文件传输助手 @bot.register(company_group) def forward_boss_message(msg): if msg.member == boss: msg.forward(bot.file_helper, prefix='老板发言') # 堵塞线程 embed() """ logger.info('{}: forwarding to {}: {}'.format(self.bot, chat, self)) def wrapped_send(send_type, *args, **kwargs): if send_type == 'msg': if args: text = args[0] elif kwargs: text = kwargs['msg'] else: text = self.text ret = chat.send_msg('{}{}{}'.format( str(prefix) + '\n' if prefix else '', text, '\n' + str(suffix) if suffix else '', )) else: if prefix: chat.send_msg(prefix) ret = getattr(chat, 'send_{}'.format(send_type))(*args, **kwargs) if suffix: chat.send_msg(suffix) return ret def download_and_send(): fd, path = tempfile.mkstemp( suffix='_{}'.format(self.file_name), dir=self.bot.temp_dir.name ) try: self.get_file(path) if self.type == PICTURE: return wrapped_send('image', path) elif self.type == VIDEO: return wrapped_send('video', path) else: return wrapped_send('file', path) finally: os.close(fd) def raise_properly(text): logger.warning(text) if raise_for_unsupported: raise NotImplementedError(text) if self.type == TEXT: return wrapped_send('msg') elif self.type == SHARING: return wrapped_send('msg', '{}\n{}'.format(self.text, self.url)) elif self.type == MAP: return wrapped_send('msg', '{}: {}\n{}'.format( self.location['poiname'], self.location['label'], self.url )) elif self.type == ATTACHMENT: # noinspection SpellCheckingInspection content = \ "" \ "{file_name}" \ "6" \ "{file_size}{media_id}" \ "{file_ext}" content = content.format( file_name=self.file_name, file_size=self.file_size, media_id=self.media_id, file_ext=os.path.splitext(self.file_name)[1].replace('.', '') ) return wrapped_send( send_type='raw_msg', raw_type=self.raw['MsgType'], raw_content=content, uri='/webwxsendappmsg?fun=async&f=json' ) elif self.type == CARD: if self.card.raw.get('AttrStatus') and self.sender != self.bot.self: # 为个人名片,且不为自己所发出 raise_properly('Personal cards sent from others are unsupported:\n{}'.format(self)) else: return wrapped_send( send_type='raw_msg', raw_type=self.raw['MsgType'], raw_content=self.raw['Content'], uri='/webwxsendmsg' ) elif self.type == PICTURE: if self.raw.get('HasProductId'): # 来自表情商店的表情 raise_properly('Stickers from store are unsupported:\n{}'.format(self)) else: return download_and_send() elif self.type == VIDEO: return download_and_send() elif self.type == RECORDING: return download_and_send() else: raise_properly('Unsupported message type:\n{}'.format(self)) ================================================ FILE: wxpy/api/messages/message_config.py ================================================ # coding: utf-8 from __future__ import unicode_literals import logging import weakref from wxpy.compatible.utils import force_encoded_string_output from wxpy.utils import ensure_list logger = logging.getLogger(__name__) class MessageConfig(object): """ 单个消息注册配置 """ def __init__( self, bot, func, chats, msg_types, except_self, run_async, enabled ): self.bot = weakref.proxy(bot) self.func = func self.chats = ensure_list(chats) self.msg_types = ensure_list(msg_types) self.except_self = except_self self.run_async = run_async self._enabled = None self.enabled = enabled @property def enabled(self): """ 配置的开启状态 """ return self._enabled @enabled.setter def enabled(self, boolean): """ 设置配置的开启状态 """ self._enabled = boolean logger.info(self) @force_encoded_string_output def __repr__(self): return '<{}: {}: {} ({}{})>'.format( self.__class__.__name__, self.bot.self.name, self.func.__name__, 'Enabled' if self.enabled else 'Disabled', ', Async' if self.run_async else '', ) def __unicode__(self): return '<{}: {}: {} ({}{})>'.format( self.__class__.__name__, self.bot.self.name, self.func.__name__, 'Enabled' if self.enabled else 'Disabled', ', Async' if self.run_async else '', ) ================================================ FILE: wxpy/api/messages/messages.py ================================================ # coding: utf-8 from __future__ import unicode_literals import threading from wxpy.utils import match_attributes, match_text class Messages(list): """ 多条消息的合集,可用于记录或搜索 """ def __init__(self, msg_list=None, max_history=200): if msg_list: super(Messages, self).__init__(msg_list) self.max_history = max_history self._thread_lock = threading.Lock() def append(self, msg): """ 仅当 self.max_history 为 int 类型,且大于 0 时才保存历史消息 """ with self._thread_lock: if isinstance(self.max_history, int) and self.max_history > 0: del self[:-self.max_history + 1] return super(Messages, self).append(msg) def search(self, keywords=None, **attributes): """ 搜索消息记录 :param keywords: 文本关键词 :param attributes: 属性键值对 :return: 所有匹配的消息 :rtype: :class:`wxpy.Messages` """ def match(msg): if not match_text(msg.text, keywords): return if not match_attributes(msg, **attributes): return return True return Messages(filter(match, self), max_history=self.max_history) ================================================ FILE: wxpy/api/messages/registered.py ================================================ # coding: utf-8 from __future__ import unicode_literals import weakref from wxpy.api.consts import SYSTEM class Registered(list): def __init__(self, bot): """ 保存当前机器人所有已注册的消息配置 :param bot: 所属的机器人 """ super(Registered, self).__init__() self.bot = weakref.proxy(bot) def get_config(self, msg): """ 获取给定消息的注册配置。每条消息仅匹配一个注册配置,后注册的配置具有更高的匹配优先级。 :param msg: 给定的消息 :return: 匹配的回复配置 """ for conf in self[::-1]: if not conf.enabled or (conf.except_self and msg.sender == self.bot.self): continue if conf.msg_types and msg.type not in conf.msg_types: continue elif conf.msg_types is None and msg.type == SYSTEM: continue if conf.chats is None: return conf for chat in conf.chats: if (isinstance(chat, type) and isinstance(msg.chat, chat)) or chat == msg.chat: return conf def get_config_by_func(self, func): """ 通过给定的函数找到对应的注册配置 :param func: 给定的函数 :return: 对应的注册配置 """ for conf in self: if conf.func == func: return conf def _change_status(self, func, enabled): if func: self.get_config_by_func(func).enabled = enabled else: for conf in self: conf.enabled = enabled def enable(self, func=None): """ 开启指定函数的对应配置。若不指定函数,则开启所有已注册配置。 :param func: 指定的函数 """ self._change_status(func, True) def disable(self, func=None): """ 关闭指定函数的对应配置。若不指定函数,则关闭所有已注册配置。 :param func: 指定的函数 """ self._change_status(func, False) def _check_status(self, enabled): ret = list() for conf in self: if conf.enabled == enabled: ret.append(conf) return ret @property def enabled(self): """ 检查处于开启状态的配置 :return: 处于开启状态的配置 """ return self._check_status(True) @property def disabled(self): """ 检查处于关闭状态的配置 :return: 处于关闭状态的配置 """ return self._check_status(False) ================================================ FILE: wxpy/api/messages/sent_message.py ================================================ # coding: utf-8 from __future__ import unicode_literals import logging from wxpy.compatible.utils import force_encoded_string_output from wxpy.utils import repr_message logger = logging.getLogger(__name__) class SentMessage(object): """ 程序中通过 .send/reply() 系列方法发出的消息 *使用程序发送的消息也将被记录到历史消息 bot.messages 中* """ def __init__(self, attributes): # 消息的类型 (仅可为 '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.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)) @force_encoded_string_output def __repr__(self): return repr_message(self) def __unicode__(self): return repr_message(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 @property def member(self): """ 若在群聊中发送消息,则为群员 """ from wxpy import Group if isinstance(self.receiver, Group): return self.receiver.self @property def bot(self): """ 消息所属的机器人 """ return self.receiver.bot @property def sender(self): """ 消息的发送者 """ return self.receiver.bot.self 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() ================================================ FILE: wxpy/compatible/__init__.py ================================================ # coding: utf-8 import sys as _sys PY_VERSION = _sys.version PY2 = PY_VERSION < '3' if PY2: from future.standard_library import print_function from future.builtins import str, int ================================================ FILE: wxpy/compatible/utils.py ================================================ # coding: utf-8 from __future__ import print_function import warnings as _warnings import os as _os import sys as _sys from tempfile import mkdtemp import sys from . import * class TemporaryDirectory(object): """Create and return a temporary directory. This has the same behavior as mkdtemp but can be used as a context manager. For example: with TemporaryDirectory() as tmpdir: ... Upon exiting the context, the directory and everything contained in it are removed. """ def __init__(self, suffix="", prefix="tmp", dir=None): self._closed = False self.name = None # Handle mkdtemp raising an exception self.name = mkdtemp(suffix, prefix, dir) def __repr__(self): return "<{} {!r}>".format(self.__class__.__name__, self.name) def __enter__(self): return self.name def cleanup(self, _warn=False): if self.name and not self._closed: try: self._rmtree(self.name) except (TypeError, AttributeError) as ex: # Issue #10188: Emit a warning on stderr # if the directory could not be cleaned # up due to missing globals if "None" not in str(ex): raise print("ERROR: {!r} while cleaning up {!r}".format(ex, self, ), file=_sys.stderr) return self._closed = True if _warn: self._warn("Implicitly cleaning up {!r}".format(self), ResourceWarning) def __exit__(self, exc, value, tb): self.cleanup() def __del__(self): # Issue a ResourceWarning if implicit cleanup needed self.cleanup(_warn=True) # XXX (ncoghlan): The following code attempts to make # this class tolerant of the module nulling out process # that happens during CPython interpreter shutdown # Alas, it doesn't actually manage it. See issue #10188 _listdir = staticmethod(_os.listdir) _path_join = staticmethod(_os.path.join) _isdir = staticmethod(_os.path.isdir) _islink = staticmethod(_os.path.islink) _remove = staticmethod(_os.remove) _rmdir = staticmethod(_os.rmdir) _warn = _warnings.warn def _rmtree(self, path): # Essentially a stripped down version of shutil.rmtree. We can't # use globals because they may be None'ed out at shutdown. for name in self._listdir(path): fullname = self._path_join(path, name) try: isdir = self._isdir(fullname) and not self._islink(fullname) except OSError: isdir = False if isdir: self._rmtree(fullname) else: try: self._remove(fullname) except OSError: pass try: self._rmdir(path) except OSError: pass def force_encoded_string_output(func): if sys.version_info.major < 3: def _func(*args, **kwargs): return func(*args, **kwargs).encode(sys.stdout.encoding or 'utf-8') return _func else: return func ================================================ FILE: wxpy/exceptions.py ================================================ # coding: utf-8 from __future__ import unicode_literals class ResponseError(Exception): """ 当 BaseResponse 的返回值不为 0 时抛出的异常 """ def __init__(self, err_code, err_msg): super(ResponseError, self).__init__( 'err_code: {}; err_msg: {}'.format(err_code, err_msg)) self.err_code = err_code self.err_msg = err_msg ================================================ FILE: wxpy/ext/__init__.py ================================================ from .logging_with_wechat import WeChatLoggingHandler, get_wechat_logger from .sync_message_in_groups import sync_message_in_groups from .tuling import Tuling from .xiaoi import XiaoI ================================================ FILE: wxpy/ext/logging_with_wechat.py ================================================ # coding: utf-8 from __future__ import unicode_literals import logging from wxpy.utils import get_receiver logger = logging.getLogger(__name__) class WeChatLoggingHandler(logging.Handler): def __init__(self, receiver=None): """ 可向指定微信聊天对象发送日志的 Logging Handler :param receiver: * 当为 `None`, `True` 或字符串时,将以该值作为 `cache_path` 参数启动一个新的机器人,并发送到该机器人的"文件传输助手" * 当为 :class:`机器人 ` 时,将发送到该机器人的"文件传输助手" * 当为 :class:`聊天对象 ` 时,将发送到该聊天对象 """ super(WeChatLoggingHandler, self).__init__() self.receiver = get_receiver(receiver) def emit(self, record): if record.name.startswith('wxpy.'): # 排除 wxpy 的日志 return # noinspection PyBroadException try: self.receiver.send(self.format(record)) except: # Todo: 将异常输出到屏幕 pass def get_wechat_logger(receiver=None, name=None, level=logging.WARNING): """ 获得一个可向指定微信聊天对象发送日志的 Logger :param receiver: * 当为 `None`, `True` 或字符串时,将以该值作为 `cache_path` 参数启动一个新的机器人,并发送到该机器人的"文件传输助手" * 当为 :class:`机器人 ` 时,将发送到该机器人的"文件传输助手" * 当为 :class:`聊天对象 ` 时,将发送到该聊天对象 :param name: Logger 名称 :param level: Logger 等级,默认为 `logging.WARNING` :return: Logger """ _logger = logging.getLogger(name=name) _logger.setLevel(level=level) _logger.addHandler(WeChatLoggingHandler(receiver=receiver)) return _logger ================================================ FILE: wxpy/ext/sync_message_in_groups.py ================================================ # coding: utf-8 from __future__ import unicode_literals from binascii import crc32 from wxpy.utils import start_new_thread emojis = \ '😀😁😂🤣😃😄😅😆😉😊😋😎😍😘😗😙😚🙂🤗🤔😐😑😶🙄😏😣😥😮🤐😯' \ '😪😫😴😌🤓😛😜😝🤤😒😓😔😕🙃🤑😲😇🤠🤡🤥😺😸😹😻😼😽🙀😿😾🙈' \ '🙉🙊🌱🌲🌳🌴🌵🌾🌿🍀🍁🍂🍃🍇🍈🍉🍊🍋🍌🍍🍏🍐🍑🍒🍓🥝🍅🥑🍆🥔' \ '🥕🌽🥒🍄🥜🌰🍞🥐🥖🥞🧀🍖🍗🥓🍔🍟🍕🌭🌮🌯🥙🥚🍳🥘🍲🥗🍿🍱🍘🍙' \ '🍚🍛🍜🍝🍠🍢🍣🍤🍥🍡🍦🍧🍨🍩🍪🎂🍰🍫🍬🍭🍮🍯🍼🥛☕🍵🍶🍾🍷🍸' \ '🍹🍺🍻🥂🥃🍴🥄🔪🏺🌍🌎🌏🌐🗾🌋🗻🏠🏡🏢🏣🏤🏥🏦🏨🏩🏪🏫🏬🏭🏯' \ '🏰💒🗼🗽⛪🕌🕍🕋⛲⛺🌁🌃🌄🌅🌆🌇🌉🌌🎠🎡🎢💈🎪🎭🎨🎰🚂🚃🚄🚅' \ '🚆🚇🚈🚉🚊🚝🚞🚋🚌🚍🚎🚐🚑🚒🚓🚔🚕🚖🚗🚘🚙🚚🚛🚜🚲🛴🛵🚏⛽🚨' \ '🚥🚦🚧⚓⛵🛶🚤🚢🛫🛬💺🚁🚟🚠🚡🚀🚪🛌🚽🚿🛀🛁⌛⏳⌚⏰🌑🌒🌓🌔' \ '🌕🌖🌗🌘🌙🌚🌛🌜🌝🌞⭐🌟🌠⛅🌀🌈🌂☔⚡⛄🔥💧🌊🎃🎄🎆🎇✨🎈🎉' \ '🎊🎋🎍🎎🎏🎐🎑🎁🎫🏆🏅🥇🥈🥉⚽⚾🏀🏐🏈🏉🎾🎱🎳🏏🏑🏒🏓🏸🥊🥋' \ '🥅🎯⛳🎣🎽🎿🎮🎲🃏🎴🔇🔈🔉🔊📢📣📯🔔🔕🎼🎵🎶🎤🎧📻🎷🎸🎹🎺🎻' \ '🥁📱📲📞📟📠🔋🔌💻💽💾💿📀🎥🎬📺📷📸📹📼🔍🔎🔬🔭📡💡🔦📔📕📖' \ '📗📘📙📚📓📒📃📜📄📰📑🔖💰💴💵💶💷💸💳💱💲📧📨📩📤📥📦📫📪📬' \ '📭📮📝💼📁📂📅📆📇📋📌📍📎📏📐🔒🔓🔏🔐🔑🔨🔫🏹🔧🔩🔗🚬🗿🔮🛒' def assign_emoji(chat): n = crc32(str(chat.wxid or chat.nick_name).encode()) & 0xffffffff return emojis[n % len(emojis)] def forward_prefix(user): # represent for avatar avatar_repr = assign_emoji(user) return '{} · {}'.format(avatar_repr, user.name) def sync_message_in_groups( msg, groups, prefix=None, suffix=None, raise_for_unsupported=False, run_async=True ): """ 将消息同步到多个微信群中 支持以下消息类型 * 文本 (`TEXT`) * 视频(`VIDEO`) * 文件 (`ATTACHMENT`) * 图片/自定义表情 (`PICTURE`) * 但不支持表情商店中的表情 * 名片 (`CARD`) * 仅支持公众号名片,以及自己发出的个人号名片 * 分享 (`SHARING`) * 会被转化为 `标题 + 链接` 形式的纯文本 * 语音 (`RECORDING`) * 会以文件方式发送 * 地图 (`MAP`) * 会转化为 `位置名称 + 地图链接` 形式的文本消息 :param Message msg: 需同步的消息对象 :param Group groups: 需同步的群列表 :param str prefix: * 转发时的 **前缀** 文本,原消息为文本时会自动换行 * 若不设定,则使用默认前缀作为提示 :param str suffix: * 转发时的 **后缀** 文本,原消息为文本时会自动换行 * 默认为空 :param bool raise_for_unsupported: | 为 True 时,将为不支持的消息类型抛出 `NotImplementedError` 异常 :param bool run_async: 是否异步执行,为 True 时不堵塞线程 :: my_groups = [group1, group2, group3 ...] @bot.register(my_groups, except_self=False) def sync_my_groups(msg): sync_message_in_groups(msg, my_groups) """ def process(): for group in groups: if group == msg.chat: continue msg.forward( chat=group, prefix=prefix, suffix=suffix, raise_for_unsupported=raise_for_unsupported ) if not prefix: prefix = forward_prefix(msg.member) if run_async: start_new_thread(process, use_caller_name=True) else: process() ================================================ FILE: wxpy/ext/talk_bot_utils.py ================================================ # coding: utf-8 from __future__ import unicode_literals import random import re def get_context_user_id(msg, max_len=32, re_sub=r'[^a-zA-Z\d]'): """ | 通过消息对象获取 Tuling, XiaoI 等聊天机器人的上下文用户 ID | 上下文用户 ID: 为群聊时,取群员的 user_name;非群聊时,取聊天对象的 user_name :param msg: 消息对象 :param max_len: 最大长度 (从末尾截取) :param re_sub: 需要移除的字符的正则表达式 (为符合聊天机器人的 API 规范) :return: 上下文用户 ID """ from wxpy.api.messages import Message from wxpy.api.chats import Group # 当 msg 不为消息对象时,返回 None if not isinstance(msg, Message): return if isinstance(msg.sender, Group): user = msg.member else: user = msg.sender user_id = re.sub(re_sub, '', user.user_name) return user_id[-max_len:] def next_topic(): """ 聊天机器人无法获取回复时的备用回复 """ return random.choice(( '换个话题吧', '聊点别的吧', '下一个话题吧', '无言以对呢', '这话我接不了呢' )) ================================================ FILE: wxpy/ext/tuling.py ================================================ # coding: utf-8 from __future__ import unicode_literals import logging import pprint import requests from wxpy.ext.talk_bot_utils import get_context_user_id, next_topic from wxpy.utils.misc import get_text_without_at_bot from wxpy.utils import enhance_connection from wxpy.compatible import * logger = logging.getLogger(__name__) class Tuling(object): """ 与 wxpy 深度整合的图灵机器人 """ 'API 文档: http://tuling123.com/help/h_cent_webapi.jhtml' # 考虑升级 API 版本: http://doc.tuling123.com/openapi2/263611 url = 'http://www.tuling123.com/openapi/api' def __init__(self, api_key=None): """ | 内置的 api key 存在调用限制,建议自行申请。 | 免费申请: http://www.tuling123.com/ :param api_key: 你申请的 api key """ self.session = requests.Session() enhance_connection(self.session) # noinspection SpellCheckingInspection self.api_key = api_key or '7c8cdb56b0dc4450a8deef30a496bd4c' self.last_member = dict() 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): """ 回复消息,并返回答复文本 :param msg: Message 对象 :param at_member: 若消息来自群聊,回复时 @发消息的群成员 :return: 答复文本 :rtype: str """ ret = self.reply_text(msg, at_member) msg.reply(ret) return ret def reply_text(self, msg, at_member=True): """ 仅返回消息的答复文本 :param msg: Message 对象 :param at_member: 若消息来自群聊,回复时 @发消息的群成员 :return: 答复文本 :rtype: str """ def process_answer(): logger.debug('Tuling answer:\n' + pprint.pformat(answer)) ret = str() if at_member: if len(msg.chat) > 2 and msg.member.name and not self.is_last_member(msg): ret += '@{} '.format(msg.member.name) code = -1 if answer: code = answer.get('code', -1) if code >= 100000: text = answer.get('text') if not text or (text == msg.text and len(text) > 3): text = next_topic() url = answer.get('url') items = answer.get('list', list()) ret += str(text) if url: ret += '\n{}'.format(url) for item in items: ret += '\n\n{}\n{}'.format( item.get('article') or item.get('name'), item.get('detailurl') ) else: ret += next_topic() return ret def get_location(_chat): province = getattr(_chat, 'province', None) or '' city = getattr(_chat, 'city', None) or '' if province in ('北京', '上海', '天津', '重庆'): return '{}市{}区'.format(province, city) elif province and city: return '{}省{}市'.format(province, city) if not msg.bot: raise ValueError('bot not found: {}'.format(msg)) if not msg.text: return from wxpy.api.chats import Group if at_member and isinstance(msg.chat, Group) and msg.member: location = get_location(msg.member) else: # 使该选项失效,防止错误 @ 人 at_member = False location = get_location(msg.chat) user_id = get_context_user_id(msg) if location: location = location[:30] info = str(get_text_without_at_bot(msg))[-30:] payload = dict( key=self.api_key, info=info, userid=user_id, loc=location ) logger.debug('Tuling payload:\n' + pprint.pformat(payload)) # noinspection PyBroadException try: r = self.session.post(self.url, json=payload) answer = r.json() except: answer = None finally: return process_answer() ================================================ FILE: wxpy/ext/xiaoi.py ================================================ # coding: utf-8 from __future__ import unicode_literals # created by: Han Feng (https://github.com/hanx11) import collections import hashlib import logging import requests from wxpy.api.messages import Message from wxpy.ext.talk_bot_utils import get_context_user_id, next_topic from wxpy.utils.misc import get_text_without_at_bot from wxpy.utils import enhance_connection logger = logging.getLogger(__name__) from wxpy.compatible import * class XiaoI(object): """ 与 wxpy 深度整合的小 i 机器人 """ # noinspection SpellCheckingInspection def __init__(self, key, secret): """ | 需要通过注册获得 key 和 secret | 免费申请: http://cloud.xiaoi.com/ :param key: 你申请的 key :param secret: 你申请的 secret """ self.key = key self.secret = secret self.realm = "xiaoi.com" self.http_method = "POST" self.uri = "/ask.do" self.url = "http://nlp.xiaoi.com/ask.do?platform=custom" xauth = self._make_http_header_xauth() headers = { "Content-type": "application/x-www-form-urlencoded", "Accept": "text/plain", } headers.update(xauth) self.session = requests.Session() self.session.headers.update(headers) enhance_connection(self.session) def _make_signature(self): """ 生成请求签名 """ # 40位随机字符 # nonce = "".join([str(randint(0, 9)) for _ in range(40)]) nonce = "4103657107305326101203516108016101205331" sha1 = "{0}:{1}:{2}".format(self.key, self.realm, self.secret).encode("utf-8") sha1 = hashlib.sha1(sha1).hexdigest() sha2 = "{0}:{1}".format(self.http_method, self.uri).encode("utf-8") sha2 = hashlib.sha1(sha2).hexdigest() signature = "{0}:{1}:{2}".format(sha1, nonce, sha2).encode("utf-8") signature = hashlib.sha1(signature).hexdigest() ret = collections.namedtuple("signature_return", "signature nonce") ret.signature = signature ret.nonce = nonce return ret def _make_http_header_xauth(self): """ 生成请求认证 """ sign = self._make_signature() ret = { "X-Auth": "app_key=\"{0}\",nonce=\"{1}\",signature=\"{2}\"".format( self.key, sign.nonce, sign.signature) } return ret def do_reply(self, msg): """ 回复消息,并返回答复文本 :param msg: Message 对象 :return: 答复文本 """ ret = self.reply_text(msg) msg.reply(ret) return ret def reply_text(self, msg): """ 仅返回答复文本 :param msg: Message 对象,或消息文本 :return: 答复文本 """ error_response = ( "主人还没给我设置这类话题的回复", ) if isinstance(msg, Message): user_id = get_context_user_id(msg) question = get_text_without_at_bot(msg) else: user_id = "abc" question = msg or "" params = { "question": question, "format": "json", "platform": "custom", "userId": user_id, } resp = self.session.post(self.url, data=params) text = resp.text for err in error_response: if err in text: return next_topic() return text ================================================ FILE: wxpy/utils/__init__.py ================================================ from .base_request import BaseRequest from .console import embed, shell_entry from .misc import decode_text_from_webwx, enhance_connection, enhance_webwx_request, ensure_list, get_receiver, \ get_text_without_at_bot, get_user_name, handle_response, match_attributes, match_name, match_text, repr_message, \ smart_map, start_new_thread, wrap_user_name from .puid_map import PuidMap from .tools import detect_freq_limit, dont_raise_response_error, ensure_one, mutual_friends ================================================ FILE: wxpy/utils/base_request.py ================================================ # coding: utf-8 from __future__ import unicode_literals import functools import json import itchat.config import itchat.returnvalues from .misc import handle_response class BaseRequest(object): def __init__(self, bot, uri, params=None): """ 基本的 Web 微信请求模板,可用于修改后发送请求 可修改属性包括: * url (会通过 url 参数自动拼接好) * data (默认仅包含 BaseRequest 部分) * headers :param bot: 所使用的机器人对象 :param uri: API 路径,将与基础 URL 进行拼接 """ self.bot = bot self.url = self.bot.core.loginInfo['url'] + uri self.params = params 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, params=self.params, data=self.data, headers=self.headers )) return do() ================================================ FILE: wxpy/utils/console.py ================================================ # coding: utf-8 from __future__ import unicode_literals import inspect from wxpy.compatible import PY2 def _ipython(local, banner): from IPython.terminal.embed import InteractiveShellEmbed from IPython.terminal.ipapp import load_default_config InteractiveShellEmbed.clear_instance() shell = InteractiveShellEmbed.instance( banner1=banner, user_ns=local, config=load_default_config() ) shell() def _bpython(local, banner): # noinspection PyUnresolvedReferences,PyPackageRequirements import bpython bpython.embed(locals_=local, banner=banner) def _python(local, banner): import code try: # noinspection PyUnresolvedReferences import readline except ImportError: pass else: import rlcompleter readline.parse_and_bind('tab:complete') if PY2: banner = banner.encode('utf-8') code.interact(local=local, banner=banner) def embed(local=None, banner='', shell=None): """ | 进入交互式的 Python 命令行界面,并堵塞当前线程 | 支持使用 ipython, bpython 以及原生 python :param str shell: | 指定命令行类型,可设为 'ipython','bpython','python',或它们的首字母; | 若为 `None`,则按上述优先级进入首个可用的 Python 命令行。 :param dict local: 设定本地变量环境,若为 `None`,则获取进入之前的变量环境。 :param str banner: 设定欢迎内容,将在进入命令行后展示。 """ import inspect if not local: local = inspect.currentframe().f_back.f_locals if isinstance(shell, str): shell = shell.strip().lower() if shell.startswith('b'): shell = _bpython elif shell.startswith('i'): shell = _ipython elif shell.startswith('p') or not shell: shell = _python for _shell in shell, _ipython, _bpython, _python: try: _shell(local=local, banner=banner) except (TypeError, ImportError): continue except KeyboardInterrupt: break else: break def get_arg_parser(): import argparse ap = argparse.ArgumentParser( description='Run a wxpy-ready python console.') ap.add_argument( 'bot', type=str, nargs='*', help='One or more variable name(s) for bot(s) to init (default: None).') ap.add_argument( '-c', '--cache', action='store_true', help='Cache session(s) for a short time, or load session(s) from cache ' '(default: disabled).') ap.add_argument( '-q', '--console_qr', type=int, default=False, metavar='width', help='The width for console_qr (default: None).') ap.add_argument( '-l', '--logging_level', type=str, default='INFO', metavar='level', help='Logging level (default: INFO).') ap.add_argument( '-s', '--shell', type=str, default=None, metavar='shell', help='Specify which shell to use: ipython, bpython, or python ' '(default: the first available).') ap.add_argument( '-v', '--version', action='store_true', help='Show version and exit.') return ap def shell_entry(): import re import logging import wxpy arg_parser = get_arg_parser() args = arg_parser.parse_args() if args.bot: def get_logging_level(): logging_level = args.logging_level.upper() for level in 'CRITICAL', 'ERROR', 'WARNING', 'INFO', 'DEBUG', 'NOTSET': if level.startswith(logging_level): return getattr(logging, level) else: return logging.INFO logging.basicConfig(level=get_logging_level()) try: bots = dict() for name in args.bot: if not re.match(r'\w+$', name): continue cache_path = 'wxpy_{}.pkl'.format(name) if args.cache else None bots[name] = wxpy.Bot(cache_path=cache_path, console_qr=args.console_qr) except KeyboardInterrupt: return banner = 'from wxpy import *\n' for k, v in bots.items(): banner += '{}: {}\n'.format(k, v) module_members = dict(inspect.getmembers(wxpy)) embed( local=dict(module_members, **bots), banner=banner, shell=args.shell ) elif args.version: print(wxpy.version_details) else: arg_parser.print_help() ================================================ FILE: wxpy/utils/misc.py ================================================ # coding: utf-8 from __future__ import unicode_literals import inspect import logging import random import re import threading import weakref from functools import wraps import requests from requests.adapters import HTTPAdapter from wxpy.compatible import PY2 from wxpy.exceptions import ResponseError if PY2: from future.builtins import str def decode_text_from_webwx(text): """ 解码从 Web 微信获得到的中文乱码 :param text: 从 Web 微信获得到的中文乱码 """ if isinstance(text, str): try: text = text.encode('raw_unicode_escape').decode() except UnicodeDecodeError: pass return text def check_response_body(response_body): """ 检查 response body: err_code 不为 0 时抛出 :class:`ResponseError` 异常 :param response_body: response body """ try: base_response = response_body['BaseResponse'] err_code = base_response['Ret'] err_msg = base_response['ErrMsg'] except (KeyError, TypeError): pass else: if err_code != 0: if int(err_code) > 0: err_msg = decode_text_from_webwx(err_msg) raise ResponseError(err_code=err_code, err_msg=err_msg) def handle_response(to_class=None): """ 装饰器:检查从 itchat 返回的字典对象,并将其转化为指定类的实例 若返回值不为0,会抛出 ResponseError 异常 :param to_class: 需转化成的类,若为None则不转换 """ def decorator(func): @wraps(func) def wrapped(*args, **kwargs): ret = func(*args, **kwargs) if ret is None: return 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 == Group: from wxpy.api.chats import Groups ret = Groups(ret) else: from wxpy.api.chats import Chats ret = Chats(ret, bot) return ret return wrapped return decorator def ensure_list(x, except_false=True): """ 若传入的对象不为列表,则转化为列表 :param x: 输入对象 :param except_false: None, False 等例外,会直接返回原值 :return: 列表,或 None, False 等 :rtype: list """ if isinstance(x, (list, tuple)) or (not x and except_false): return x return [x] def prepare_keywords(keywords): """ 准备关键词 """ if not keywords: keywords = '' if isinstance(keywords, str): # noinspection PyTypeChecker keywords = re.split(r'\s+', keywords) return map(lambda x: x.lower(), keywords) def match_text(text, keywords): """ 判断文本内容中是否包含了所有的关键词 (不区分大小写) :param text: 文本内容 :param keywords: 关键词,可以是空白分割的 str,或是多个精准关键词组成的 list :return: 若包含了所有的关键词则为 True,否则为 False """ if not text: text = '' else: text = text.lower() keywords = prepare_keywords(keywords) for kw in keywords: if kw not in text: return False return True def match_attributes(obj, **attributes): """ 判断对象是否匹配输入的属性条件 :param obj: 对象 :param attributes: 属性键值对 :return: 若匹配则为 True,否则为 False """ has_raw = hasattr(obj, 'raw') for attr, value in attributes.items(): if (getattr(obj, attr, None) or (obj.raw.get(attr) if has_raw else None)) != value: return False return True def match_name(chat, keywords): """ 判断一个 Chat 对象的名称是否包含了所有的关键词 (不区分大小写) :param chat: Chat 对象 :param keywords: 关键词,可以是空白分割的 str,或是多个精准关键词组成的 list :return: 若包含了所有的关键词则为 True,否则为 False """ keywords = prepare_keywords(keywords) for kw in keywords: for attr in 'remark_name', 'display_name', 'nick_name', 'wxid': if kw in '{0}'.format(getattr(chat, attr, '')).lower(): break else: return False return True def smart_map(func, i, *args, **kwargs): """ 将单个对象或列表中的每个项传入给定的函数,并返回单个结果或列表结果,类似于 map 函数 :param func: 传入到的函数 :param i: 列表或单个对象 :param args: func 函数所需的 args :param kwargs: func 函数所需的 kwargs :return: 若传入的为列表,则以列表返回每个结果,反之为单个结果 """ if isinstance(i, (list, tuple, set)): return list(map(lambda x: func(x, *args, **kwargs), i)) else: return func(i, *args, **kwargs) def wrap_user_name(user_or_users): """ 确保将用户转化为带有 UserName 键的用户字典 :param user_or_users: 单个用户,或列表形式的多个用户 :return: 单个用户字典,或列表形式的多个用户字典 """ from wxpy.api.chats import Chat def wrap_one(x): if isinstance(x, dict): return x elif isinstance(x, Chat): return x.raw elif isinstance(x, str): return {'UserName': user_or_users} else: if PY2: # noinspection PyUnresolvedReferences if isinstance(x, unicode): return {'UserName': user_or_users} raise TypeError('Unsupported type: {}'.format(type(x))) return smart_map(wrap_one, user_or_users) def get_user_name(user_or_users): """ 确保将用户转化为 user_name 字串 :param user_or_users: 单个用户,或列表形式的多个用户 :return: 返回单个 user_name 字串,或列表形式的多个 user_name 字串 """ from wxpy.api.chats import Chat def get_one(x): if isinstance(x, Chat): return x.user_name elif isinstance(x, dict): return x['UserName'] elif isinstance(x, str): return x else: if PY2: # noinspection PyUnresolvedReferences if isinstance(x, unicode): return x raise TypeError('Unsupported type: {}'.format(type(x))) return smart_map(get_one, user_or_users) def get_receiver(receiver=None): """ 获得作为接收者的聊天对象 :param receiver: * 当为 `None`, `True` 或字符串时,将以该值作为 `cache_path` 参数启动一个新的机器人,并返回该机器人的"文件传输助手" * 当为 :class:`机器人 ` 时,将返回该机器人的"文件传输助手" * 当为 :class:`聊天对象 ` 时,将返回该聊天对象 :return: 作为接收者的聊天对象 :rtype: :class:`wxpy.Chat` """ from wxpy.api.chats import Chat from wxpy.api.bot import Bot if isinstance(receiver, Chat): return receiver elif isinstance(receiver, Bot): return receiver.file_helper elif receiver in (None, True) or isinstance(receiver, str): return Bot(cache_path=receiver).file_helper else: raise TypeError('expected Chat, Bot, str, True or None') def enhance_connection(session, pool_connections=30, pool_maxsize=30, max_retries=30): """ 增强 requests.Session 对象的网络连接性能 :param session: 需增强的 requests.Session 对象 :param pool_connections: 最大的连接池缓存数量 :param pool_maxsize: 连接池中的最大连接保存数量 :param max_retries: 最大的连接重试次数 (仅处理 DNS 查询, socket 连接,以及连接超时) """ for p in 'http', 'https': session.mount( '{}://'.format(p), HTTPAdapter( pool_connections=pool_connections, pool_maxsize=pool_maxsize, max_retries=max_retries, pool_block=False )) def enhance_webwx_request(bot, sync_check_timeout=(10, 30), webwx_sync_timeout=(10, 20)): """ 针对 Web 微信增强机器人的网络请求 :param bot: 需优化的机器人实例 :param sync_check_timeout: 请求 "synccheck" 时的超时秒数 :param webwx_sync_timeout: 请求 "webwxsync" 时的超时秒数 """ login_info = bot.core.loginInfo session = bot.core.s # get: 用于检查是否有新消息 sync_check_url = '{}/synccheck'.format(login_info.get('syncUrl', login_info['url'])) # post: 用于获取消息和更新联系人 webwx_sync_url = '{li[url]}/webwxsync?sid={li[wxsid]}&skey={li[skey]}' \ '&pass_ticket={li[pass_ticket]}'.format(li=login_info) # noinspection PyProtectedMember def customized_request(method, url, **kwargs): """ 根据 请求方法 和 url 灵活调整各种参数 """ if method.upper() == 'GET': if url == sync_check_url: # 设置一个超时,避免无尽等待而停止发送心跳,导致出现 1101 错误 kwargs['timeout'] = sync_check_timeout # deviceid 应每次都变化,否则会导致该连接断开不及时,接收消息变慢 kwargs['params']['deviceid'] = 'e{}'.format(str(random.random())[2:17]) bot._sync_check_iterations += 1 kwargs['params']['_'] = bot._sync_check_iterations elif method.upper() == 'POST': if url == webwx_sync_url: # 同上方设置超时 kwargs['timeout'] = webwx_sync_timeout return requests.Session.request(session, method, url, **kwargs) session.request = customized_request def repr_message(msg): """ 用于 Message 和 SentMessage 对象的 __repr__ 和 __unicode__ """ from wxpy.api.chats import Group text = (str(msg.text or '')).replace('\n', ' ↩ ') text += ' ' if text else '' if msg.sender == msg.bot.self: ret = '↪ {self.receiver.name}' elif isinstance(msg.chat, Group) and msg.member != msg.receiver: ret = '{self.sender.name} › {self.member.name}' else: ret = '{self.sender.name}' ret += ' : {text}({self.type})' return ret.format(self=msg, text=text) def get_text_without_at_bot(msg): """ 获得 Message 对象中的消息内容,并清理 @ 机器人的部分 :param msg: Message 对象 :return: 清理 @ 机器人部分后的文本内容 :rtype: str """ from wxpy.api.chats import Group text = msg.text if isinstance(msg.chat, Group): name = msg.chat.self.name 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)) if PY2: _thread = threading.Thread( target=target, args=args, kwargs=kwargs, name=name) _thread.setDaemon(daemon) else: _thread = threading.Thread( target=target, args=args, kwargs=kwargs, name=name, daemon=daemon ) _thread.start() return _thread ================================================ FILE: wxpy/utils/puid_map.py ================================================ # coding: utf-8 from __future__ import unicode_literals import atexit import os import pickle import threading from wxpy.compatible import PY2 if PY2: from UserDict import UserDict else: from collections import UserDict """ # puid 尝试用聊天对象已知的属性,来查找对应的持久固定并且唯一的 用户 id ## 数据结构 PuidMap 中包含 4 个 dict,分别为 1. user_name -> puid 2. wxid -> puid 3. remark_name -> puid 4. caption (昵称, 性别, 省份, 城市) -> puid ## 查询逻辑 当给定一个 Chat 对象,需要获取对应的 puid 时,将按顺序,使用自己的对应属性,轮询以上 4 个 dict * 若匹配任何一个,则获取到 puid,并将其他属性更新到其他的 dict * 如果没有一个匹配,则创建一个新的 puid,并加入到以上的 4 个 dict """ class PuidMap(object): def __init__(self, path): """ 用于获取聊天对象的 puid (持续有效,并且稳定唯一的用户ID),和保存映射关系 :param path: 映射数据的保存/载入路径 """ self.path = path self.user_names = TwoWayDict() self.wxids = TwoWayDict() self.remark_names = TwoWayDict() self.captions = TwoWayDict() self._thread_lock = threading.Lock() if os.path.exists(self.path): self.load() atexit.register(self.dump) @property def attr_dicts(self): return self.user_names, self.wxids, self.remark_names def __len__(self): return len(self.user_names) def __bool__(self): return bool(self.path) def __nonzero__(self): return bool(self.path) def get_puid(self, chat): """ 获取指定聊天对象的 puid :param chat: 指定的聊天对象 :return: puid :rtype: str """ with self._thread_lock: if not (chat.user_name and chat.nick_name): return chat_attrs = ( chat.user_name, chat.wxid, getattr(chat, 'remark_name', None), ) chat_caption = get_caption(chat) puid = None for i in range(3): puid = self.attr_dicts[i].get(chat_attrs[i]) if puid: break else: if PY2: captions = self.captions.keys() else: captions = self.captions for caption in captions: if match_captions(caption, chat_caption): puid = self.captions[caption] break if puid: new_caption = merge_captions(self.captions.get_key(puid), chat_caption) else: puid = chat.user_name[-8:] new_caption = get_caption(chat) for i in range(3): chat_attr = chat_attrs[i] if chat_attr: self.attr_dicts[i][chat_attr] = puid self.captions[new_caption] = puid return puid def dump(self): """ 保存映射数据 """ with open(self.path, 'wb') as fp: pickle.dump((self.user_names, self.wxids, self.remark_names, self.captions), fp) def load(self): """ 载入映射数据 """ with open(self.path, 'rb') as fp: self.user_names, self.wxids, self.remark_names, self.captions = pickle.load(fp) class TwoWayDict(UserDict): """ 可双向查询,且 key, value 均为唯一的 dict 限制: key, value 均须为不可变对象,且不支持 .update() 方法 """ def __init__(self): if PY2: UserDict.__init__(self) else: super(TwoWayDict, self).__init__() self._reversed = dict() def get_key(self, value): """ 通过 value 查找 key """ return self._reversed.get(value) def del_value(self, value): """ 删除 value 及对应的 key """ del self[self._reversed[value]] def __setitem__(self, key, value): if self.get(key) != value: if key in self: self.del_value(self[key]) if value in self._reversed: del self[self.get_key(value)] self._reversed[value] = key if PY2: return UserDict.__setitem__(self, key, value) else: return super(TwoWayDict, self).__setitem__(key, value) def __delitem__(self, key): del self._reversed[self[key]] if PY2: return UserDict.__delitem__(self, key) else: return super(TwoWayDict, self).__delitem__(key) def update(*args, **kwargs): raise NotImplementedError def get_caption(chat): return ( chat.nick_name, getattr(chat, 'sex', None), getattr(chat, 'province', None), getattr(chat, 'city', None), ) def match_captions(old, new): if new[0]: for i in range(4): if old[i] and new[i] and old[i] != new[i]: return False return True def merge_captions(old, new): return tuple(new[i] or old[i] for i in range(4)) ================================================ FILE: wxpy/utils/tools.py ================================================ # coding: utf-8 from __future__ import unicode_literals import logging import time from functools import wraps from wxpy.exceptions import ResponseError logger = logging.getLogger(__name__) def dont_raise_response_error(func): """ 装饰器:用于避免被装饰的函数在运行过程中抛出 ResponseError 错误 """ @wraps(func) def wrapped(*args, **kwargs): try: return func(*args, **kwargs) except ResponseError as e: logger.warning('{0.__class__.__name__}: {0}'.format(e)) return wrapped def ensure_one(found): """ 确保列表中仅有一个项,并返回这个项,否则抛出 `ValueError` 异常 通常可用在查找聊天对象时,确保查找结果的唯一性,并直接获取唯一项 :param found: 列表 :return: 唯一项 """ if not isinstance(found, list): raise TypeError('expected list, {} found'.format(type(found))) elif not found: raise ValueError('not found') elif len(found) > 1: raise ValueError('more than one found') else: return found[0] def mutual_friends(*args): """ 找到多个微信用户的共同好友 :param args: 每个参数为一个微信用户的机器人(Bot),或是聊天对象合集(Chats) :return: 共同好友列表 :rtype: :class:`wxpy.Chats` """ from wxpy.api.bot import Bot from wxpy.api.chats import Chats, User class FuzzyUser(User): def __init__(self, user): super(FuzzyUser, self).__init__(user.raw, user.bot) def __hash__(self): return hash((self.nick_name, self.sex, self.province, self.city, self.raw['AttrStatus'])) mutual = set() for arg in args: if isinstance(arg, Bot): friends = map(FuzzyUser, arg.friends()) elif isinstance(arg, Chats): friends = map(FuzzyUser, arg) else: raise TypeError if mutual: mutual &= set(friends) else: mutual.update(friends) return Chats(mutual) def detect_freq_limit(func, *args, **kwargs): """ 检测各类 Web 微信操作的频率限制,获得限制次数和周期 :param func: 需要执行的操作函数 :param args: 操作函数的位置参数 :param kwargs: 操作函数的命名参数 :return: 限制次数, 限制周期(秒数) """ start = time.time() count = 0 while True: try: func(*args, **kwargs) except ResponseError as e: logger.info('freq limit reached: {} requests passed, error_info: {}'.format(count, e)) break else: count += 1 logger.debug('{} passed'.format(count)) while True: period = time.time() - start try: func(*args, **kwargs) except ResponseError: logger.debug('blocking: {:.0f} secs'.format(period)) time.sleep(1) else: logger.info('freq limit detected: {} requests / {:.0f} secs'.format(count, period)) return count, period