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