├── .gitignore ├── LICENSE ├── MANIFEST.in ├── README.rst ├── docs ├── Makefile ├── api │ ├── chat.rst │ ├── message.rst │ ├── robot.rst │ └── utils.rst ├── conf.py ├── index.rst ├── make.bat └── wechat-group.png ├── requirements.txt ├── setup.py └── wxpy ├── __init__.py ├── bot.py ├── chat.py ├── chats.py ├── contrib ├── __init__.py └── tuling.py ├── friend.py ├── group.py ├── groups.py ├── member.py ├── message.py ├── mp.py ├── response.py ├── user.py ├── util.py └── utils ├── __init__.py ├── constants.py └── tools.py /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | _private 3 | 4 | ### Linux template 5 | *~ 6 | 7 | # temporary files which can be created if a process still has a handle open of a deleted file 8 | .fuse_hidden* 9 | 10 | # KDE directory preferences 11 | .directory 12 | 13 | # Linux trash folder which might appear on any partition or disk 14 | .Trash-* 15 | 16 | # .nfs files are created when an open file is removed but is still being accessed 17 | .nfs* 18 | ### Windows template 19 | # Windows thumbnail cache files 20 | Thumbs.db 21 | ehthumbs.db 22 | ehthumbs_vista.db 23 | 24 | # Folder config file 25 | Desktop.ini 26 | 27 | # Recycle Bin used on file shares 28 | $RECYCLE.BIN/ 29 | 30 | # Windows Installer files 31 | *.cab 32 | *.msi 33 | *.msm 34 | *.msp 35 | 36 | # Windows shortcuts 37 | *.lnk 38 | ### JetBrains template 39 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm 40 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 41 | 42 | # Sensitive or high-churn files: 43 | .idea/**/dataSources/ 44 | .idea/**/dataSources.ids 45 | .idea/**/dataSources.xml 46 | .idea/**/dataSources.local.xml 47 | .idea/**/sqlDataSources.xml 48 | .idea/**/dynamic.xml 49 | .idea/**/uiDesigner.xml 50 | 51 | # Gradle: 52 | .idea/**/gradle.xml 53 | .idea/**/libraries 54 | 55 | # Mongo Explorer plugin: 56 | .idea/**/mongoSettings.xml 57 | 58 | ## File-based project format: 59 | *.iws 60 | 61 | ## Plugin-specific files: 62 | 63 | # IntelliJ 64 | /out/ 65 | 66 | # mpeltonen/sbt-idea plugin 67 | .idea_modules/ 68 | 69 | # JIRA plugin 70 | atlassian-ide-plugin.xml 71 | 72 | # Crashlytics plugin (for Android Studio and IntelliJ) 73 | com_crashlytics_export_strings.xml 74 | crashlytics.properties 75 | crashlytics-build.properties 76 | fabric.properties 77 | ### macOS template 78 | *.DS_Store 79 | .AppleDouble 80 | .LSOverride 81 | 82 | # Icon must end with two \r 83 | Icon 84 | 85 | 86 | # Thumbnails 87 | ._* 88 | 89 | # Files that might appear in the root of a volume 90 | .DocumentRevisions-V100 91 | .fseventsd 92 | .Spotlight-V100 93 | .TemporaryItems 94 | .Trashes 95 | .VolumeIcon.icns 96 | .com.apple.timemachine.donotpresent 97 | 98 | # Directories potentially created on remote AFP share 99 | .AppleDB 100 | .AppleDesktop 101 | Network Trash Folder 102 | Temporary Items 103 | .apdisk 104 | ### Python template 105 | # Byte-compiled / optimized / DLL files 106 | __pycache__/ 107 | *.py[cod] 108 | *$py.class 109 | 110 | # C extensions 111 | *.so 112 | 113 | # Distribution / packaging 114 | .Python 115 | env/ 116 | build/ 117 | develop-eggs/ 118 | dist/ 119 | downloads/ 120 | eggs/ 121 | .eggs/ 122 | lib/ 123 | lib64/ 124 | parts/ 125 | sdist/ 126 | var/ 127 | wheels/ 128 | *.egg-info/ 129 | .installed.cfg 130 | *.egg 131 | 132 | # PyInstaller 133 | # Usually these files are written by a python script from a template 134 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 135 | *.manifest 136 | *.spec 137 | 138 | # Installer logs 139 | pip-log.txt 140 | pip-delete-this-directory.txt 141 | 142 | # Unit test / coverage reports 143 | htmlcov/ 144 | .tox/ 145 | .coverage 146 | .coverage.* 147 | .cache 148 | nosetests.xml 149 | coverage.xml 150 | *,cover 151 | .hypothesis/ 152 | 153 | # Translations 154 | *.mo 155 | *.pot 156 | 157 | # Django stuff: 158 | *.log 159 | local_settings.py 160 | 161 | # Flask stuff: 162 | instance/ 163 | .webassets-cache 164 | 165 | # Scrapy stuff: 166 | .scrapy 167 | 168 | # Sphinx documentation 169 | docs/_build/ 170 | 171 | # PyBuilder 172 | target/ 173 | 174 | # Jupyter Notebook 175 | .ipynb_checkpoints 176 | 177 | # pyenv 178 | .python-version 179 | 180 | # celery beat schedule file 181 | celerybeat-schedule 182 | 183 | # SageMath parsed files 184 | *.sage.py 185 | 186 | # dotenv 187 | .env 188 | 189 | # virtualenv 190 | .venv 191 | venv/ 192 | ENV/ 193 | 194 | # Spyder project settings 195 | .spyderproject 196 | 197 | # Rope project settings 198 | .ropeproject 199 | 200 | -------------------------------------------------------------------------------- /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 | 优雅的微信个人号 机器人/API,基于 itchat,全面优化接口,更有 Python 范儿 5 | 6 | 7 | 用来干啥 8 | ---------------- 9 | 10 | 一些常见的场景 11 | 12 | * 控制路由器、智能家居等具有开放接口的玩意儿 13 | * 跑脚本时自动把日志发送到你的微信 14 | * 加群主为好友,自动拉进群中 15 | * 充当各种信息查询 16 | * 转发消息 17 | * 逗人玩 18 | * ... [1]_ 19 | 20 | 总而言之,可用来实现各种微信个人号的自动化操作 21 | 22 | .. [1] 脑洞太大的就不提了... 23 | 24 | 25 | 轻松安装 26 | ---------------- 27 | 28 | 使用 Python 3.x :: 29 | 30 | pip3 install -U wxpy 31 | 32 | 33 | 简单上手 34 | ---------------- 35 | 36 | 37 | 登陆微信:: 38 | 39 | # 导入模块 40 | from wxpy import * 41 | # 初始化机器人,扫码登陆 42 | robot = Robot() 43 | 44 | 找到好友:: 45 | 46 | # 搜索名称含有 "游否" 的男性深圳好友 47 | my_friend = robot.friends().search('游否', sex=MALE, city="深圳")[0] 48 | 49 | 发送消息:: 50 | 51 | # 发送文本给好友 52 | my_friend.send('Hello WeChat!') 53 | # 发送图片 54 | my_friend.send_image('my_picture.jpg') 55 | 56 | 自动响应各类消息:: 57 | 58 | # 打印来自其他好友、群聊和公众号的消息 59 | @robot.register() 60 | def print_others(msg): 61 | print(msg) 62 | 63 | # 回复 my_friend 的消息 (优先匹配后注册的函数!) 64 | @robot.register(my_friend) 65 | def reply_my_friend(msg): 66 | return 'received: {} ({})'.format(msg.text, msg.type) 67 | 68 | # 开始监听和自动处理消息 69 | robot.start() 70 | 71 | 72 | 模块特色 73 | ---------------- 74 | 75 | * 全面对象化接口,调用更优雅 76 | * 默认多线程响应消息,回复更快 77 | * 附带 共同好友、图灵机器人 等实用组件 78 | * 覆盖大部分常用功能: 79 | 80 | * 发送文本、图片、视频、文件 81 | * 通过关键词或用户属性搜索 好友、群聊、群成员 等 82 | * 获取好友/群成员昵称、备注、性别、地区 83 | * 加好友,建群,邀请进群,踢出群 84 | 85 | 86 | 了解更多 87 | ---------------- 88 | 89 | 说明文档: http://wxpy.readthedocs.io 90 | 91 | 加入讨论 92 | ---------------- 93 | 94 | GitHub: https://github.com/youfou/wxpy 95 | 96 | -------- 97 | 98 | 加入微信交流群 (真的是群哦) 99 | 100 | * 加以下微信,填写验证 [ **wxpy** ],即可自动受邀入群 101 | 102 | .. image:: docs/wechat-group.png 103 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command 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/api/chat.rst: -------------------------------------------------------------------------------- 1 | 聊天对象 2 | ============================== 3 | 4 | .. module:: wxpy 5 | 6 | 通过机器人对象 :class:`Robot ` 的 :meth:`chats() `,:meth:`friends() `,:meth:`groups() `,:meth:`mps() ` 方法,可分别获取到当前机器人的 所有聊天对象、好友、群聊,以及公众号列表。 7 | 8 | 而获得到的聊天对象合集 :class:`Chats` 和 :class:`Groups` 具有一些合集方法,例如::meth:`Chats.search` 可用于按条件搜索聊天对象:: 9 | 10 | from wxpy import * 11 | robot = Robot() 12 | my_friend = robot.friends().search('游否', sex=MALE, city='深圳')[0] 13 | # 14 | 15 | 在找到好友(或其他聊天对象)后,还可使用该聊天对象的 :meth:`send ` 系列方法,对其发送消息:: 16 | 17 | # 发送文本 18 | my_friend.send('Hello, WeChat!') 19 | # 发送图片 20 | my_friend.send_image('my_picture.png') 21 | # 发送视频 22 | my_friend.send_video('my_video.mov') 23 | # 发送文件 24 | my_friend.send_file('my_file.zip') 25 | # 以动态的方式发送图片 26 | my_friend.send('@img@my_picture.png') 27 | 28 | 29 | 基本聊天对象 30 | -------------------------------------- 31 | 32 | 所有聊天对象都继承于以下两种基本聊天对象,并拥有相应的方法 33 | 34 | .. autoclass:: Chat 35 | :members: 36 | 37 | .. autoclass:: User 38 | :members: 39 | 40 | 好友 41 | ------------------- 42 | 43 | .. autoclass:: Friend 44 | :members: 45 | 46 | 群聊 47 | ------------------- 48 | 49 | .. autoclass:: Group 50 | :members: 51 | 52 | 53 | 群聊成员 54 | ^^^^^^^^^^^^^^^^^^^^ 55 | 56 | .. autoclass:: Member 57 | :members: 58 | 59 | 公众号 60 | ------------------- 61 | 62 | .. autoclass:: MP 63 | :members: 64 | 65 | 聊天对象合集 66 | ------------------- 67 | 68 | 好友、公众号、群聊成员的合集 69 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 70 | 71 | 在 :class:`Chats` 对象中,除了最常用到的 :meth:`search() ` 外,还有两个特别的方法,:meth:`stats() ` 与 :meth:`stats_text() `,可用来统计好友或群成员的性别和地区分布:: 72 | 73 | robot.friends().stats_text() 74 | # 游否 共有 100 位微信好友\n\n男性: 67 (67.0%)\n女性: 23 (23.0%) ... 75 | 76 | .. autoclass:: Chats 77 | :members: 78 | 79 | 群聊的合集 80 | ^^^^^^^^^^^^^^^^^^^^ 81 | 82 | .. autoclass:: Groups 83 | :members: 84 | 85 | -------------------------------------------------------------------------------- /docs/api/message.rst: -------------------------------------------------------------------------------- 1 | 消息处理 2 | ============================== 3 | 4 | .. module:: wxpy 5 | 6 | 每当机器人接收到消息时,会自动执行以下两个步骤 7 | 8 | 1. 将消息保存到 `Robot.messages` 中 9 | 2. 查找消息预先注册的函数,并执行(若有注册) 10 | 11 | 消息对象 12 | ---------------- 13 | 14 | .. autoclass:: Message 15 | :members: 16 | 17 | 18 | 除以上外,还有以下属性: 19 | 20 | * `text`: 消息的文本内容 21 | * `robot`: 接收消息的机器人对象 22 | * `type`: 消息的类型 23 | * `is_at`: 当消息来自群聊,且被 @ 时,为 True 24 | * `file_name`: 文件名 (图片、视频等带有文件的消息) 25 | * `img_height`: 图片高度 26 | * `img_width`: 图片宽度 27 | * `play_length`: 视频长度 28 | * `url`: 分享类消息的 URL (非文件类消息) 29 | * `voice_length`: 语音长度 30 | * `id`: 消息ID 31 | * `get_file`: 获取文件(包括图片、视频等)的方法 32 | * `location`: 地图消息的地理位置信息 33 | * `card`: 名片消息或好友请求的用户对象 34 | * `create_time`: 消息的发送时间戳 35 | 36 | 还有以下方法,可用于回复消息: 37 | 38 | * `reply()`: 等同于 :meth:`Message.chat.send() ` 39 | * `reply_image()`: 等同于 :meth:`Message.chat.send_image() ` 40 | * `reply_file()`: 等同于 :meth:`Message.chat.send_file() ` 41 | * `reply_video()`: 等同于 :meth:`Message.chat.send_video() ` 42 | * `reply_msg()`: 等同于 :meth:`Message.chat.send_msg() ` 43 | * `reply_raw_msg()`: 等同于 :meth:`Message.chat.send_raw_msg() ` 44 | 45 | 消息类型 46 | ^^^^^^^^^^^^^^^ 47 | 48 | 可通过 `Message.type` 判断消息类型。 49 | 50 | 目前有以下消息类型:: 51 | 52 | # 文本 53 | TEXT = 'Text' 54 | # 位置 55 | MAP = 'Map' 56 | # 名片 57 | CARD = 'Card' 58 | # 提示 59 | NOTE = 'Note' 60 | # 分享 61 | SHARING = 'Sharing' 62 | # 图片 63 | PICTURE = 'Picture' 64 | # 语音 65 | RECORDING = 'Recording' 66 | # 文件 67 | ATTACHMENT = 'Attachment' 68 | # 视频 69 | VIDEO = 'Video' 70 | # 好友请求 71 | FRIENDS = 'Friends' 72 | # 系统 73 | SYSTEM = 'System' 74 | 75 | 76 | 自动处理消息 77 | --------------------- 78 | 79 | 可通过 **预先注册** 的方式,实现消息的自动处理。 80 | 81 | .. hint:: **预先注册**: 预先将来自特定聊天对象的特定类型的消息,注册到相应的处理函数。 82 | 83 | 消息注册 84 | ^^^^^^^^^^^^^^ 85 | 86 | 将 :meth:`以下方法 ` 作为函数的装饰器,即可完成注册。 87 | 88 | 当接收到符合条件的消息时,会自动执行被注册的函数,并以参数的形式传入 :class:`消息对象 `。 89 | 90 | .. automethod:: Robot.register 91 | 92 | .. note:: 每条消息仅匹配一个预先注册函数,且优先匹配后注册的函数! 93 | 94 | .. tip:: 95 | 96 | 1. `chats` 和 `msg_types` 参数可以接收一个列表或干脆一个单项。按需使用,方便灵活。 97 | 2. `chats` 参数既可以是聊天对象实例,也可以是对象类。当为类时,表示匹配该类型的所有聊天对象。 98 | 3. 在被注册函数中,可以直接通过 `return <回复内容>` 的方式来回复消息,等同于调用 `msg.reply(<回复内容>)`。 99 | 100 | 开始监听 101 | ^^^^^^^^^^^^^^ 102 | 103 | .. note:: 在完成消息注册后,务必通过以下方法开始监听和处理消息。 104 | 105 | .. automethod:: Robot.start 106 | 107 | 示例代码 108 | ^^^^^^^^^^^^^ 109 | 110 | 在以下例子中,机器人将 111 | 112 | * 忽略 "一个无聊的群" 的所有消息 113 | * 回复好友 "游否" 和其他群聊中被 @ 的 TEXT 类消息 114 | * 打印所有其他消息 115 | 116 | 初始化机器人,并找到好友和群聊:: 117 | 118 | from wxpy import * 119 | robot = Robot() 120 | my_friend = robot.friends().search('游否')[0] 121 | boring_group = robot.groups().search('一个无聊的群')[0] 122 | 123 | 打印所有其他消息:: 124 | 125 | @robot.register() 126 | def just_print(msg): 127 | # 打印消息 128 | print(msg) 129 | 130 | 回复好友"游否"和其他群聊中被 @ 的 TEXT 类消息:: 131 | 132 | @robot.register([my_friend, Group], TEXT) 133 | def auto_reply(msg): 134 | # 如果是群聊,但没有被 @,则不回复 135 | if not (isinstance(msg.chat, Group) and not msg.is_at): 136 | # 回复消息内容和类型 137 | return '收到消息: {} ({})'.format(msg.text, msg.type) 138 | 139 | 忽略"一个无聊的群"的所有消息:: 140 | 141 | @robot.register(boring_group) 142 | def ignore(msg): 143 | # 啥也不做 144 | return 145 | 146 | 147 | 开始监听和自动处理:: 148 | 149 | robot.start() 150 | 151 | 152 | 动态开关注册配置 153 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 154 | 155 | .. note:: 该操作需要在额外的线程中进行! 156 | 157 | 158 | 查看当前的注册配置情况:: 159 | 160 | robot.message_configs 161 | # [, 162 | # , 163 | # ] 164 | 165 | 关闭所有注册配置:: 166 | 167 | robot.message_configs.disable() 168 | 169 | 重新开启 `just_print` 函数:: 170 | 171 | robot.message_configs.enable(just_print) 172 | 173 | 查看当前开启的注册配置:: 174 | 175 | robot.message_configs.enabled 176 | # [] 177 | 178 | -------------------------------------------------------------------------------- /docs/api/robot.rst: -------------------------------------------------------------------------------- 1 | 机器人对象 2 | ============================== 3 | 4 | .. module:: wxpy 5 | 6 | 机器人(:class:`Robot`)对象可被理解为一个抽象的 Web 微信客户端。 7 | 8 | 9 | .. note:: 10 | 11 | | 关于发送消息,请参见 :doc:`chat`。 12 | | 关于消息对象和自动处理,请参见 :doc:`message`。 13 | 14 | 15 | 初始化 16 | ---------------- 17 | 18 | .. note:: 19 | 20 | :class:`Robot` 在初始化时便会执行登陆操作,需要手机扫描登陆。 21 | 22 | .. autoclass:: Robot 23 | 24 | 25 | 获取聊天对象 26 | ---------------- 27 | 28 | .. automethod:: Robot.chats 29 | 30 | .. automethod:: Robot.friends 31 | 32 | .. automethod:: Robot.groups 33 | 34 | .. automethod:: Robot.mps 35 | 36 | 37 | 加好友和建群 38 | ---------------- 39 | 40 | .. automethod:: Robot.add_friend 41 | 42 | .. automethod:: Robot.accept_friend 43 | 44 | .. automethod:: Robot.create_group 45 | 46 | 47 | 获取用户详细信息 48 | ---------------- 49 | 50 | .. automethod:: Robot.user_details 51 | 52 | 53 | 登出 54 | ---------------- 55 | 56 | .. automethod:: Robot.logout 57 | -------------------------------------------------------------------------------- /docs/api/utils.rst: -------------------------------------------------------------------------------- 1 | 实用组件 2 | ============================== 3 | 4 | 额外内置了一些实用的小组件,可按需使用。 5 | 6 | .. module:: wxpy 7 | 8 | .. automodule:: wxpy.utils 9 | :members: 10 | -------------------------------------------------------------------------------- /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 command 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 | -------------------------------------------------------------------------------- /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 | 优雅的微信个人号 机器人/API,基于 itchat,全面优化接口,更有 Python 范儿 11 | 12 | 13 | 用来干啥 14 | ---------------- 15 | 16 | 一些常见的场景 17 | 18 | * 控制路由器、智能家居等具有开放接口的玩意儿 19 | * 跑脚本时自动把日志发送到你的微信 20 | * 加群主为好友,自动拉进群中 21 | * 充当各种信息查询 22 | * 转发消息 23 | * 逗人玩 24 | * ... [1]_ 25 | 26 | 总而言之,可用来实现各种微信个人号的自动化操作 27 | 28 | .. [1] 脑洞太大的就不提了... 29 | 30 | 31 | 轻松安装 32 | ---------------- 33 | 34 | 使用 Python 3.x :: 35 | 36 | pip3 install -U wxpy 37 | 38 | 39 | 简单上手 40 | ---------------- 41 | 42 | .. automodule:: wxpy 43 | 44 | 45 | 模块特色 46 | ---------------- 47 | 48 | * 全面对象化接口,调用更优雅 49 | * 默认多线程响应消息,回复更快 50 | * 附带 :any:`共同好友 `、:any:`图灵机器人 ` 等实用组件 51 | * 覆盖大部分常用功能: 52 | 53 | * 发送文本、图片、视频、文件 54 | * 通过关键词或用户属性搜索 好友、群聊、群成员 等 55 | * 获取好友/群成员昵称、备注、性别、地区 56 | * 加好友,建群,邀请进群,踢出群 57 | 58 | 59 | 文档目录 60 | ---------------- 61 | 62 | .. toctree:: 63 | :maxdepth: 2 64 | 65 | api/robot 66 | api/chat 67 | api/message 68 | api/utils 69 | 70 | 71 | 加入讨论 72 | ---------------- 73 | 74 | GitHub: https://github.com/youfou/wxpy 75 | 76 | -------- 77 | 78 | 加入微信交流群 (真的是群哦) 79 | 80 | * 加以下微信,填写验证 [ **wxpy** ],即可自动受邀入群 81 | 82 | .. image:: wechat-group.png 83 | -------------------------------------------------------------------------------- /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' command 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/wechat-group.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daimajia/wxpy/2b56fb67b9ccb072538fd778a27a8fef8d9c93e6/docs/wechat-group.png -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | itchat>=1.2.27 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from setuptools import find_packages, setup 4 | 5 | with open('wxpy/__init__.py', encoding='utf-8') as fp: 6 | version = re.search(r"__version__\s*=\s*'([\d.]+)'", fp.read()).group(1) 7 | 8 | with open('README.rst', encoding='utf-8') as fp: 9 | readme = fp.read() 10 | 11 | setup( 12 | name='wxpy', 13 | version=version, 14 | packages=find_packages(), 15 | package_data={ 16 | '': ['*.rst'], 17 | }, 18 | include_package_data=True, 19 | install_requires=[ 20 | 'itchat>=1.2.27', 21 | ], 22 | url='https://github.com/youfou/wxpy', 23 | license='MIT', 24 | author='Youfou', 25 | author_email='youfou@qq.com', 26 | description='微信个人号 API,基于 itchat,告别满屏 dict,更有 Python 范儿', 27 | long_description=readme, 28 | keywords=[ 29 | '微信', 30 | 'WeChat', 31 | 'API' 32 | ], 33 | classifiers=[ 34 | 'Development Status :: 4 - Beta', 35 | 'License :: OSI Approved :: Apache Software License', 36 | 'Programming Language :: Python :: 3', 37 | 'Operating System :: OS Independent', 38 | 'Topic :: Communications :: Chat', 39 | 'Topic :: Utilities', 40 | ] 41 | ) 42 | -------------------------------------------------------------------------------- /wxpy/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """ 3 | 4 | 5 | 登陆微信:: 6 | 7 | # 导入模块 8 | from wxpy import * 9 | # 初始化机器人,扫码登陆 10 | robot = Robot() 11 | 12 | 找到好友:: 13 | 14 | # 搜索名称含有 "游否" 的男性深圳好友 15 | my_friend = robot.friends().search('游否', sex=MALE, city="深圳")[0] 16 | 17 | 发送消息:: 18 | 19 | # 发送文本给好友 20 | my_friend.send('Hello WeChat!') 21 | # 发送图片 22 | my_friend.send_image('my_picture.jpg') 23 | 24 | 自动响应各类消息:: 25 | 26 | # 打印来自其他好友、群聊和公众号的消息 27 | @robot.register() 28 | def print_others(msg): 29 | print(msg) 30 | 31 | # 回复 `my_friend` 的消息 (优先匹配后注册的函数!) 32 | @robot.register(my_friend) 33 | def reply_my_friend(msg): 34 | return 'received: {} ({})'.format(msg.text, msg.type) 35 | 36 | # 开始监听和自动处理消息 37 | robot.start() 38 | 39 | 40 | """ 41 | 42 | __title__ = 'wxpy' 43 | __version__ = '0.0.9' 44 | __author__ = 'Youfou' 45 | __license__ = 'MIT' 46 | __copyright__ = '2017, Youfou' 47 | 48 | from wxpy.bot import Robot 49 | from wxpy.chat import Chat 50 | from wxpy.chats import Chats 51 | from wxpy.friend import Friend 52 | from wxpy.group import Group 53 | from wxpy.groups import Groups 54 | from wxpy.member import Member 55 | from wxpy.mp import MP 56 | from wxpy.response import Response, ResponseError 57 | from wxpy.message import Message, MessageConfig, MessageConfigs, Messages 58 | from wxpy.user import User 59 | -------------------------------------------------------------------------------- /wxpy/bot.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | from pprint import pformat 3 | from threading import Thread 4 | 5 | import itchat 6 | import logging 7 | 8 | from wxpy.chat import Chat 9 | from wxpy.chats import Chats 10 | from wxpy.friend import Friend 11 | from wxpy.group import Group 12 | from wxpy.message import MessageConfigs, Messages, Message, MessageConfig 13 | from wxpy.mp import MP 14 | from wxpy.response import ResponseError 15 | from wxpy.user import User 16 | from wxpy.utils.constants import SYSTEM 17 | from wxpy.utils.tools import handle_response, get_user_name, wrap_user_name, ensure_list 18 | 19 | logger = logging.getLogger('wxpy') 20 | 21 | class Robot(object): 22 | """ 23 | 机器人对象,用于登陆和操作微信账号,涵盖大部分 Web 微信的功能 24 | """ 25 | 26 | def __init__( 27 | self, save_path=None, console_qr=False, qr_path=None, 28 | qr_callback=None, login_callback=None, logout_callback=None 29 | ): 30 | """ 31 | :param save_path: 32 | | 用于保存或载入登陆状态的文件路径,例如: 'wxpy.pkl',为空则不尝试载入。 33 | | 填写本参数后,可在短时间内重新载入登陆状态,避免重复扫码,失效时会重新要求登陆 34 | :param console_qr: 在终端中显示登陆二维码,需要安装 Pillow 模块 35 | :param qr_path: 保存二维码的路径 36 | :param qr_callback: 获得二维码时的回调,接收参数: uuid, status, qrcode 37 | :param login_callback: 登陆时的回调,接收参数同上 38 | :param logout_callback: 登出时的回调,接收参数同上 39 | """ 40 | 41 | self.core = itchat.Core() 42 | itchat.instanceList.append(self) 43 | 44 | self.core.auto_login( 45 | hotReload=bool(save_path), statusStorageDir=save_path, 46 | enableCmdQR=console_qr, picDir=qr_path, qrCallback=qr_callback, 47 | loginCallback=login_callback, exitCallback=logout_callback 48 | ) 49 | 50 | self.message_configs = MessageConfigs(self) 51 | self.messages = Messages(robot=self) 52 | 53 | self.file_helper = Chat(wrap_user_name('filehelper')) 54 | self.file_helper.robot = self 55 | self.file_helper.nick_name = '文件传输助手' 56 | 57 | self.self = Chat(self.core.loginInfo['User']) 58 | self.self.robot = self 59 | 60 | self.save_path = save_path 61 | 62 | def __repr__(self): 63 | return '<{}: {}>'.format(self.__class__.__name__, self.self.name) 64 | 65 | @handle_response() 66 | def logout(self): 67 | """ 68 | 登出当前账号 69 | """ 70 | 71 | return self.core.logout() 72 | 73 | @property 74 | def alive(self): 75 | """ 76 | 当前的登陆状态 77 | 78 | :return: 若为登陆状态,则为 True,否则为 False 79 | """ 80 | 81 | return self.core.alive 82 | 83 | @alive.setter 84 | def alive(self, value): 85 | self.core.alive = value 86 | 87 | def dump_login_status(self, save_path=None): 88 | return self.core.dump_login_status(save_path or self.save_path) 89 | 90 | # chats 91 | 92 | def except_self(self, chats_or_dicts): 93 | """ 94 | 从聊天对象合集或用户字典列表中排除自身 95 | 96 | :param chats_or_dicts: 聊天对象合集或用户字典列表 97 | :return: 排除自身后的列表 98 | """ 99 | return list(filter(lambda x: get_user_name(x) != self.self.user_name, chats_or_dicts)) 100 | 101 | def chats(self, update=False): 102 | """ 103 | 获取所有聊天对象 104 | 105 | :param update: 是否更新 106 | :return: 聊天对象合集 107 | """ 108 | return Chats(self.friends(update) + self.groups(update) + self.mps(update), self) 109 | 110 | def friends(self, update=False): 111 | """ 112 | 获取所有好友 113 | 114 | :param update: 是否更新 115 | :return: 聊天对象合集 116 | """ 117 | 118 | @handle_response(Friend) 119 | def do(): 120 | return self.core.get_friends(update=update) 121 | 122 | ret = do() 123 | ret.source = self 124 | 125 | return ret 126 | 127 | @handle_response(Group) 128 | def groups(self, update=False, contact_only=False): 129 | """ 130 | 获取所有群聊 131 | 132 | :param update: 是否更新 133 | :param contact_only: 是否限于保存为联系人的群聊 134 | :return: 群聊合集 135 | """ 136 | return self.core.get_chatrooms(update=update, contactOnly=contact_only) 137 | 138 | @handle_response(MP) 139 | def mps(self, update=False): 140 | """ 141 | 获取所有公众号 142 | 143 | :param update: 是否更新 144 | :return: 聊天对象合集 145 | """ 146 | return self.core.get_mps(update=update) 147 | 148 | @handle_response(User) 149 | def user_details(self, user_or_users, chunk_size=50): 150 | """ 151 | 获取单个或批量获取多个用户的详细信息(地区、性别、签名等),但不可用于群聊成员 152 | 153 | :param user_or_users: 单个或多个用户对象或 user_name 154 | :param chunk_size: 分配请求时的单批数量,目前为 50 155 | :return: 单个或多个用户用户的详细信息 156 | """ 157 | 158 | def chunks(): 159 | total = ensure_list(user_or_users) 160 | for i in range(0, len(total), chunk_size): 161 | yield total[i:i + chunk_size] 162 | 163 | @handle_response() 164 | def process_one_chunk(_chunk): 165 | return self.core.update_friend(userName=get_user_name(_chunk)) 166 | 167 | if isinstance(user_or_users, (list, tuple)): 168 | ret = list() 169 | for chunk in chunks(): 170 | chunk_ret = process_one_chunk(chunk) 171 | if isinstance(chunk_ret, list): 172 | ret += chunk_ret 173 | else: 174 | ret.append(chunk_ret) 175 | return ret 176 | else: 177 | return process_one_chunk(user_or_users) 178 | 179 | def search(self, name=None, **attributes): 180 | """ 181 | 在所有类型的聊天对象中进行搜索 182 | 183 | :param name: 名称 (可以是昵称、备注等) 184 | :param attributes: 属性键值对,键可以是 sex(性别), province(省份), city(城市) 等。例如可指定 province='广东' 185 | :return: 匹配的聊天对象合集 186 | """ 187 | 188 | return self.chats().search(name, **attributes) 189 | 190 | # add / create 191 | 192 | @handle_response() 193 | def add_friend(self, user, verify_content=''): 194 | """ 195 | 添加用户为好友 196 | 197 | :param user: 用户对象或用户名 198 | :param verify_content: 验证说明信息 199 | """ 200 | return self.core.add_friend( 201 | userName=get_user_name(user), 202 | status=2, 203 | verifyContent=verify_content, 204 | autoUpdate=True 205 | ) 206 | 207 | @handle_response() 208 | def accept_friend(self, user, verify_content=''): 209 | """ 210 | 接受用户为好友 211 | 212 | :param user: 用户对象或用户名 213 | :param verify_content: 验证说明信息 214 | """ 215 | 216 | # Todo: 验证好友接口可用性,并在接受好友时直接返回新好友 217 | 218 | return self.core.add_friend( 219 | userName=get_user_name(user), 220 | status=3, 221 | verifyContent=verify_content, 222 | autoUpdate=True 223 | ) 224 | 225 | def create_group(self, users, topic=None): 226 | """ 227 | 创建一个新的群聊 228 | 229 | :param users: 用户列表 230 | :param topic: 群名称 231 | :return: 若建群成功,返回一个新的群聊对象 232 | """ 233 | 234 | @handle_response() 235 | def request(): 236 | return self.core.create_chatroom( 237 | memberList=wrap_user_name(users), 238 | topic=topic or '' 239 | ) 240 | 241 | ret = request() 242 | user_name = ret.get('ChatRoomName') 243 | if user_name: 244 | return Group(self.core.update_chatroom(userName=user_name)) 245 | else: 246 | raise ResponseError('Failed to create group:\n{}'.format(pformat(ret))) 247 | 248 | # messages 249 | 250 | def _process_message(self, msg): 251 | """ 252 | 处理接收到的消息 253 | """ 254 | 255 | if not self.alive: 256 | return 257 | 258 | func, run_async = self.message_configs.get_func(msg) 259 | 260 | if not func: 261 | return 262 | 263 | def process(): 264 | # noinspection PyBroadException 265 | try: 266 | ret = func(msg) 267 | if ret is not None: 268 | if isinstance(ret, (tuple, list)): 269 | self.core.send( 270 | msg=str(ret[0]), 271 | toUserName=msg.chat.user_name, 272 | mediaId=ret[1] 273 | ) 274 | else: 275 | self.core.send( 276 | msg=str(ret), 277 | toUserName=msg.chat.user_name 278 | ) 279 | except: 280 | logger.warning( 281 | 'An error occurred in registered function, ' 282 | 'use `Robot().start(debug=True)` to show detailed information') 283 | logger.debug(traceback.format_exc()) 284 | 285 | if run_async: 286 | Thread(target=process).start() 287 | else: 288 | process() 289 | 290 | def register( 291 | self, chats=None, msg_types=None, 292 | except_self=True, run_async=True, enabled=True 293 | ): 294 | """ 295 | 装饰器:用于注册消息配置 296 | 297 | :param chats: 单个或列表形式的多个聊天对象或聊天类型,为空时匹配所有聊天对象 298 | :param msg_types: 单个或列表形式的多个消息类型,为空时匹配所有消息类型 (SYSTEM 类消息除外) 299 | :param except_self: 排除自己在手机上发送的消息 300 | :param run_async: 异步执行配置的函数,可提高响应速度 301 | :param enabled: 当前配置的默认开启状态,可事后动态开启或关闭 302 | """ 303 | 304 | def register(func): 305 | self.message_configs.append(MessageConfig( 306 | robot=self, func=func, chats=chats, msg_types=msg_types, 307 | except_self=except_self, run_async=run_async, enabled=enabled 308 | )) 309 | 310 | return func 311 | 312 | return register 313 | 314 | def start(self, block=True): 315 | """ 316 | 开始监听和处理消息 317 | 318 | :param block: 是否堵塞线程,为 False 时将在新的线程中运行 319 | """ 320 | 321 | def listen(): 322 | 323 | logger.info('{} Auto-reply started.'.format(self)) 324 | try: 325 | while self.alive: 326 | msg = Message(self.core.msgList.get(), self) 327 | if msg.type is not SYSTEM: 328 | self.messages.append(msg) 329 | self._process_message(msg) 330 | except KeyboardInterrupt: 331 | logger.info('KeyboardInterrupt received, ending...') 332 | self.alive = False 333 | if self.core.useHotReload: 334 | self.dump_login_status() 335 | logger.info('Bye.') 336 | 337 | if block: 338 | listen() 339 | else: 340 | t = Thread(target=listen, daemon=True) 341 | t.start() 342 | -------------------------------------------------------------------------------- /wxpy/chat.py: -------------------------------------------------------------------------------- 1 | from wxpy.utils.tools import handle_response 2 | 3 | 4 | class Chat(dict): 5 | """ 6 | 单个用户(:class:`User`)和群聊(:class:`Group`)的基础类 7 | """ 8 | 9 | def __init__(self, response): 10 | super(Chat, self).__init__(response) 11 | 12 | self.robot = getattr(response, 'robot', None) 13 | self.user_name = self.get('UserName') 14 | self.nick_name = self.get('NickName') 15 | 16 | @property 17 | def raw(self): 18 | """ 19 | 原始数据 20 | """ 21 | return dict(self) 22 | 23 | @handle_response() 24 | def send(self, msg, media_id=None): 25 | """ 26 | 动态发送不同类型的消息,具体类型取决于 `msg` 的前缀。 27 | 28 | :param msg: 29 | | 由 **前缀** 和 **内容** 两个部分组成,若 **省略前缀**,将作为纯文本消息发送 30 | | **前缀** 部分可为: '@fil@', '@img@', '@msg@', '@vid@' (不含引号) 31 | | 分别表示: 文件,图片,纯文本,视频 32 | | **内容** 部分可为: 文件、图片、视频的路径,或纯文本的内容 33 | :param media_id: 填写后可省略上传过程 34 | """ 35 | return self.robot.core.send(msg=str(msg), toUserName=self.user_name, mediaId=media_id) 36 | 37 | @handle_response() 38 | def send_image(self, path, media_id=None): 39 | """ 40 | 发送图片 41 | 42 | :param path: 文件路径 43 | :param media_id: 设置后可省略上传 44 | """ 45 | return self.robot.core.send_image(fileDir=path, toUserName=self.user_name, mediaId=media_id) 46 | 47 | @handle_response() 48 | def send_file(self, path, media_id=None): 49 | """ 50 | 发送文件 51 | 52 | :param path: 文件路径 53 | :param media_id: 设置后可省略上传 54 | """ 55 | return self.robot.core.send_file(fileDir=path, toUserName=self.user_name, mediaId=media_id) 56 | 57 | @handle_response() 58 | def send_video(self, path=None, media_id=None): 59 | """ 60 | 发送视频 61 | 62 | :param path: 文件路径 63 | :param media_id: 设置后可省略上传 64 | """ 65 | return self.robot.core.send_video(fileDir=path, toUserName=self.user_name, mediaId=media_id) 66 | 67 | @handle_response() 68 | def send_msg(self, msg='Hello WeChat! -- by wxpy'): 69 | """ 70 | 发送文本消息 71 | 72 | :param msg: 文本内容 73 | """ 74 | return self.robot.core.send_msg(msg=str(msg), toUserName=self.user_name) 75 | 76 | @handle_response() 77 | def send_raw_msg(self, msg_type, content): 78 | """ 79 | 以原始格式发送其他类型的消息。例如,好友名片:: 80 | 81 | import wxpy 82 | robot = wxpy.Robot() 83 | @robot.register(msg_types=wxpy.CARD) 84 | def reply_text(msg): 85 | msg.chat.send_raw_msg(msg['MsgType'], msg['Content']) 86 | 87 | """ 88 | return self.robot.core.send_raw_msg(msgType=msg_type, content=content, toUserName=self.user_name) 89 | 90 | @handle_response() 91 | def pin(self): 92 | """ 93 | 将聊天对象置顶 94 | """ 95 | return self.robot.core.set_pinned(userName=self.user_name, isPinned=True) 96 | 97 | @handle_response() 98 | def unpin(self): 99 | """ 100 | 取消聊天对象的置顶状态 101 | """ 102 | return self.robot.core.set_pinned(userName=self.user_name, isPinned=False) 103 | 104 | @property 105 | def name(self): 106 | for attr in 'display_name', 'remark_name', 'nick_name', 'alias': 107 | _name = getattr(self, attr, None) 108 | if _name: 109 | return _name 110 | 111 | def __repr__(self): 112 | return '<{}: {}>'.format(self.__class__.__name__, self.name) 113 | 114 | def __eq__(self, other): 115 | return hash(self) == hash(other) 116 | 117 | def __hash__(self): 118 | return hash((Chat, self.user_name)) 119 | -------------------------------------------------------------------------------- /wxpy/chats.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import time 4 | from collections import Counter 5 | 6 | from wxpy.group import Group 7 | from wxpy.utils.constants import MALE, FEMALE 8 | from wxpy.utils.tools import ensure_list, match_name 9 | 10 | 11 | class Chats(list): 12 | """ 13 | 多个聊天对象的合集,可用于搜索或统计 14 | """ 15 | 16 | def __init__(self, chat_list=None, source=None): 17 | if chat_list: 18 | super(Chats, self).__init__(chat_list) 19 | self.source = source 20 | 21 | def __add__(self, other): 22 | return Chats(super(Chats, self).__add__(other or list())) 23 | 24 | def search(self, name=None, **attributes): 25 | """ 26 | 在合集中进行搜索 27 | 28 | :param name: 名称 (可以是昵称、备注等) 29 | :param attributes: 属性键值对,键可以是 sex(性别), province(省份), city(城市) 等。例如可指定 province='广东' 30 | :return: 匹配的聊天对象合集 31 | """ 32 | 33 | def match(user): 34 | if not match_name(user, name): 35 | return 36 | for attr, value in attributes.items(): 37 | if (getattr(user, attr, None) or user.get(attr)) != value: 38 | return 39 | return True 40 | 41 | if name: 42 | name = name.lower() 43 | return Chats(filter(match, self), self.source) 44 | 45 | def stats(self, attribs=('sex', 'province', 'city')): 46 | """ 47 | 统计各属性的分布情况 48 | 49 | :param attribs: 需统计的属性列表或元组 50 | :return: 统计结果 51 | """ 52 | 53 | def attr_stat(objects, attr_name): 54 | return Counter(list(map(lambda x: getattr(x, attr_name), objects))) 55 | 56 | attribs = ensure_list(attribs) 57 | ret = dict() 58 | for attr in attribs: 59 | ret[attr] = attr_stat(self, attr) 60 | return ret 61 | 62 | def stats_text(self, total=True, sex=True, top_provinces=10, top_cities=10): 63 | """ 64 | 简单的统计结果的文本 65 | 66 | :param total: 总体数量 67 | :param sex: 性别分布 68 | :param top_provinces: 省份分布 69 | :param top_cities: 城市分布 70 | :return: 统计结果文本 71 | """ 72 | 73 | def top_n_text(attr, n): 74 | top_n = list(filter(lambda x: x[0], stats[attr].most_common()))[:n] 75 | top_n = ['{}: {} ({:.2%})'.format(k, v, v / len(self)) for k, v in top_n] 76 | return '\n'.join(top_n) 77 | 78 | stats = self.stats() 79 | 80 | text = str() 81 | 82 | if total: 83 | if self.source: 84 | from wxpy.bot import Robot 85 | if isinstance(self.source, Robot): 86 | user_title = '微信好友' 87 | nick_name = self.source.self.nick_name 88 | elif isinstance(self.source, Group): 89 | user_title = '群成员' 90 | nick_name = self.source.nick_name 91 | else: 92 | raise TypeError('source should be Robot or Group') 93 | text += '{nick_name} 共有 {total} 位{user_title}\n\n'.format( 94 | nick_name=nick_name, 95 | total=len(self), 96 | user_title=user_title 97 | ) 98 | else: 99 | text += '共有 {} 位用户\n\n'.format(len(self)) 100 | 101 | if sex and self: 102 | males = stats['sex'].get(MALE, 0) 103 | females = stats['sex'].get(FEMALE, 0) 104 | 105 | text += '男性: {males} ({male_rate:.1%})\n女性: {females} ({female_rate:.1%})\n\n'.format( 106 | males=males, 107 | male_rate=males / len(self), 108 | females=females, 109 | female_rate=females / len(self), 110 | ) 111 | 112 | if top_provinces and self: 113 | text += 'TOP {} 省份\n{}\n\n'.format( 114 | top_provinces, 115 | top_n_text('province', top_provinces) 116 | ) 117 | 118 | if top_cities and self: 119 | text += 'TOP {} 城市\n{}\n\n'.format( 120 | top_cities, 121 | top_n_text('city', top_cities) 122 | ) 123 | 124 | return text 125 | 126 | def add_all(self, interval=1, verify_content='', auto_update=True): 127 | """ 128 | 将合集中的所有用户加为好友,请小心应对调用频率限制! 129 | 130 | :param interval: 间隔时间(秒) 131 | :param verify_content: 验证说明文本 132 | :param auto_update: 自动更新到好友中 133 | :return: 134 | """ 135 | for user in self: 136 | logging.info('Adding {}'.format(user.name)) 137 | ret = user.add(verify_content, auto_update) 138 | logging.info(ret) 139 | logging.info('Waiting for {} seconds'.format(interval)) 140 | time.sleep(interval) 141 | -------------------------------------------------------------------------------- /wxpy/contrib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daimajia/wxpy/2b56fb67b9ccb072538fd778a27a8fef8d9c93e6/wxpy/contrib/__init__.py -------------------------------------------------------------------------------- /wxpy/contrib/tuling.py: -------------------------------------------------------------------------------- 1 | import pprint 2 | from random import random 3 | 4 | import logging 5 | 6 | import re 7 | import requests 8 | 9 | from wxpy.group import Group 10 | 11 | 12 | class Tuling(object): 13 | """ 14 | | 与 wxpy 深度整合的图灵机器人 15 | | 内置 API KEY 可能存在调用限制,建议自行申请一枚新的。 16 | | 获取 API KEY: http://www.tuling123.com/ 17 | """ 18 | 19 | 'API 文档: http://tuling123.com/help/h_cent_webapi.jhtml' 20 | 21 | # Todo: 升级 API 版本: http://doc.tuling123.com/openapi2/263611 22 | 23 | url = 'http://www.tuling123.com/openapi/api' 24 | 25 | def __init__(self, api_key=None): 26 | """ 27 | :param api_key: 图灵机器人服务所需的 API KEY (详见: http://www.tuling123.com/) 28 | """ 29 | self.session = requests.Session() 30 | 31 | # noinspection SpellCheckingInspection 32 | self.api_key = api_key or '7c8cdb56b0dc4450a8deef30a496bd4c' 33 | self.last_member = dict() 34 | 35 | @property 36 | def _change_words(self): 37 | return random.choice(( 38 | '换个话题吧', 39 | '聊点别的吧', 40 | '下一个话题吧', 41 | '无言以对呢', 42 | '这话我接不了呢' 43 | )) 44 | 45 | def is_last_member(self, msg): 46 | if msg.member == self.last_member.get(msg.chat): 47 | return True 48 | else: 49 | self.last_member[msg.chat] = msg.member 50 | 51 | def do_reply(self, msg, to_member=True): 52 | """ 53 | 回复消息,并返回答复文本 54 | 55 | :param msg: Message 对象 56 | :param to_member: 若消息来自群聊,回复 @发消息的群成员 57 | :return: 答复文本 58 | """ 59 | ret = self.reply_text(msg, to_member) 60 | msg.reply(ret) 61 | return ret 62 | 63 | def reply_text(self, msg, to_member=True): 64 | """ 65 | 返回消息的答复文本 66 | 67 | :param msg: Message 对象 68 | :param to_member: 若消息来自群聊,回复 @发消息的群成员 69 | :return: 答复文本 70 | """ 71 | 72 | def process_answer(): 73 | 74 | logging.debug('Tuling answer:\n' + pprint.pformat(answer)) 75 | 76 | ret = str() 77 | if to_member: 78 | if len(msg.chat) > 2 and msg.member.name and not self.is_last_member(msg): 79 | ret += '@{} '.format(msg.member.name) 80 | 81 | code = -1 82 | if answer: 83 | code = answer.get('code', -1) 84 | 85 | if code >= 100000: 86 | text = answer.get('text') 87 | if not text or (text == msg.text and len(text) > 3): 88 | text = self._change_words 89 | url = answer.get('url') 90 | items = answer.get('list', list()) 91 | 92 | ret += str(text) 93 | if url: 94 | ret += '\n{}'.format(url) 95 | for item in items: 96 | ret += '\n\n{}\n{}'.format( 97 | item.get('article') or item.get('name'), 98 | item.get('detailurl') 99 | ) 100 | 101 | else: 102 | ret += self._change_words 103 | 104 | return ret 105 | 106 | def get_location(_chat): 107 | 108 | province = getattr(_chat, 'province', None) or '' 109 | city = getattr(_chat, 'city', None) or '' 110 | 111 | if province in ('北京', '上海', '天津', '重庆'): 112 | return '{}市{}区'.format(province, city) 113 | elif province and city: 114 | return '{}省{}市'.format(province, city) 115 | 116 | if not msg.robot: 117 | raise ValueError('Robot not found: {}'.format(msg)) 118 | 119 | if not msg.text: 120 | return 121 | 122 | if to_member and isinstance(msg.chat, Group) and msg.member: 123 | user_id = msg.member.user_name 124 | location = get_location(msg.member) 125 | else: 126 | to_member = False 127 | user_id = msg.chat.user_name 128 | location = get_location(msg.chat) 129 | 130 | user_id = re.sub(r'[^a-zA-Z\d]', '', user_id) 131 | user_id = user_id[-32:] 132 | if location: 133 | location = location[:30] 134 | info = str(msg.text)[-30:] 135 | 136 | payload = dict( 137 | key=self.api_key, 138 | info=info, 139 | user_id=user_id, 140 | loc=location 141 | ) 142 | 143 | logging.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/friend.py: -------------------------------------------------------------------------------- 1 | from wxpy.user import User 2 | 3 | 4 | class Friend(User): 5 | """ 6 | 好友对象 7 | """ 8 | 9 | pass 10 | -------------------------------------------------------------------------------- /wxpy/group.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from wxpy.chat import Chat 4 | from wxpy.member import Member 5 | from wxpy.utils.tools import wrap_user_name, ensure_list, handle_response, get_user_name 6 | 7 | 8 | class Group(Chat): 9 | """ 10 | 群聊对象 11 | """ 12 | 13 | def __init__(self, response): 14 | super(Group, self).__init__(response) 15 | from wxpy.chats import Chats 16 | self._members = Chats(source=self) 17 | for raw in self.get('MemberList', list()): 18 | member = Member(raw, self) 19 | member.robot = self.robot 20 | self._members.append(member) 21 | 22 | @property 23 | def members(self): 24 | """ 25 | 群聊的成员列表 26 | """ 27 | if not self._members or not self._members[-1].nick_name: 28 | self.update_group() 29 | return self._members 30 | 31 | def __contains__(self, user): 32 | user = wrap_user_name(user) 33 | for member in self.members: 34 | if member == user: 35 | return member 36 | 37 | def __iter__(self): 38 | for member in self.members: 39 | yield member 40 | 41 | def __getitem__(self, x): 42 | if isinstance(x, (int, slice)): 43 | return self.members.__getitem__(x) 44 | else: 45 | return super(Group, self).__getitem__(x) 46 | 47 | def __len__(self): 48 | return len(self.members) 49 | 50 | def search(self, name=None, **attributes): 51 | """ 52 | 在群聊中搜索成员 53 | 54 | :param name: 成员名称关键词 55 | :param attributes: 属性键值对 56 | :return: 匹配的群聊成员 57 | """ 58 | return self.members.search(name, **attributes) 59 | 60 | @property 61 | def owner(self): 62 | """ 63 | 返回群主对象 64 | """ 65 | owner_user_name = self.get('ChatRoomOwner') 66 | if owner_user_name: 67 | for member in self: 68 | if member.user_name == owner_user_name: 69 | return member 70 | elif self.members: 71 | return self[0] 72 | 73 | @property 74 | def is_owner(self): 75 | """ 76 | 判断所属 robot 是否为群管理员 77 | """ 78 | return self.get('IsOwner') == 1 or self.owner == self.robot.self 79 | 80 | def update_group(self, members_details=False): 81 | """ 82 | 更新群聊的信息 83 | 84 | :param members_details: 是否包括群聊成员的详细信息 (地区、性别、签名等) 85 | """ 86 | 87 | @handle_response() 88 | def do(): 89 | return self.robot.core.update_chatroom(self.user_name, members_details) 90 | 91 | self.__init__(do()) 92 | 93 | @handle_response() 94 | def add_members(self, users, use_invitation=False): 95 | """ 96 | 向群聊中加入用户 97 | 98 | :param users: 待加入的用户列表或单个用户 99 | :param use_invitation: 使用发送邀请的方式 100 | """ 101 | 102 | return self.robot.core.add_member_into_chatroom( 103 | self.user_name, 104 | ensure_list(wrap_user_name(users)), 105 | use_invitation 106 | ) 107 | 108 | @handle_response() 109 | def remove_members(self, members): 110 | """ 111 | 从群聊中移除用户 112 | 113 | :param members: 待移除的用户列表或单个用户 114 | """ 115 | 116 | return self.robot.core.delete_member_from_chatroom( 117 | self.user_name, 118 | ensure_list(wrap_user_name(members)) 119 | ) 120 | 121 | def rename_group(self, name): 122 | """ 123 | 修改群聊名称 124 | 125 | :param name: 新的名称,超长部分会被截断 (最长32字节) 126 | """ 127 | 128 | encodings = ('gbk', 'utf-8') 129 | 130 | trimmed = False 131 | 132 | for ecd in encodings: 133 | for length in range(32, 24, -1): 134 | try: 135 | name = bytes(name.encode(ecd))[:length].decode(ecd) 136 | except (UnicodeEncodeError, UnicodeDecodeError): 137 | continue 138 | else: 139 | trimmed = True 140 | break 141 | if trimmed: 142 | break 143 | 144 | @handle_response() 145 | def do(): 146 | if self.name != name: 147 | logging.info('renaming group: {} => {}'.format(self.name, name)) 148 | return self.robot.core.set_chatroom_name(get_user_name(self), name) 149 | 150 | ret = do() 151 | self.update_group() 152 | return ret 153 | -------------------------------------------------------------------------------- /wxpy/groups.py: -------------------------------------------------------------------------------- 1 | from wxpy.utils.tools import match_name 2 | 3 | 4 | class Groups(list): 5 | """ 6 | 群聊的合集,可用于按条件搜索 7 | """ 8 | 9 | def __init__(self, group_list=None): 10 | if group_list: 11 | super(Groups, self).__init__(group_list) 12 | 13 | def search(self, name=None, users=None, **attributes): 14 | """ 15 | 根据给定的条件搜索合集中的群聊 16 | 17 | :param name: 群聊名称 18 | :param users: 需包含的用户 19 | :param attributes: 属性键值对,键可以是 owner(群主对象), is_owner(自身是否为群主), nick_name(精准名称) 等。 20 | :return: 匹配条件的群聊列表 21 | """ 22 | 23 | def match(group): 24 | if not match_name(group, name): 25 | return 26 | if users: 27 | for user in users: 28 | if user not in group: 29 | return 30 | for attr, value in attributes.items(): 31 | if (getattr(group, attr, None) or group.get(attr)) != value: 32 | return 33 | return True 34 | 35 | return Groups(filter(match, self)) 36 | 37 | -------------------------------------------------------------------------------- /wxpy/member.py: -------------------------------------------------------------------------------- 1 | from wxpy.user import User 2 | 3 | 4 | class Member(User): 5 | """ 6 | 群聊成员对象 7 | """ 8 | 9 | def __init__(self, raw, group): 10 | super().__init__(raw) 11 | self.group = group 12 | -------------------------------------------------------------------------------- /wxpy/message.py: -------------------------------------------------------------------------------- 1 | import datetime 2 | import logging 3 | 4 | from wxpy.chat import Chat 5 | from wxpy.chats import Chats 6 | from wxpy.group import Group 7 | from wxpy.member import Member 8 | from wxpy.user import User 9 | from wxpy.utils.constants import MAP, CARD, FRIENDS, SYSTEM 10 | from wxpy.utils.tools import ensure_list, wrap_user_name, match_name 11 | from xml.etree import ElementTree as ETree 12 | 13 | 14 | class MessageConfig(object): 15 | """ 16 | 单个消息注册配置 17 | """ 18 | 19 | def __init__( 20 | self, robot, func, chats, msg_types, 21 | except_self, run_async, enabled 22 | ): 23 | self.robot = robot 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 | self.run_async = run_async 30 | 31 | self._enabled = None 32 | self.enabled = enabled 33 | 34 | @property 35 | def enabled(self): 36 | return self._enabled 37 | 38 | @enabled.setter 39 | def enabled(self, value): 40 | self._enabled = value 41 | logging.info(self.__repr__()) 42 | 43 | def __repr__(self): 44 | return '<{}: {}: {} ({}{})>'.format( 45 | self.__class__.__name__, 46 | self.robot.self.name, 47 | self.func.__name__, 48 | 'Async, ' if self.run_async else '', 49 | 'Enabled' if self.enabled else 'Disabled', 50 | ) 51 | 52 | 53 | class MessageConfigs(list): 54 | """ 55 | 一个机器人(Robot)的所有消息注册配置 56 | """ 57 | 58 | def __init__(self, robot): 59 | """ 60 | 初始化 61 | 62 | :param robot: 这些配置所属的机器人 63 | """ 64 | super(MessageConfigs, self).__init__() 65 | self.robot = robot 66 | 67 | def get_func(self, msg): 68 | """ 69 | 获取给定消息的对应回复函数。每条消息仅匹配和执行一个回复函数,后注册的配置具有更高的匹配优先级。 70 | 71 | :param msg: 给定的消息 72 | :return: 回复函数 func,及是否异步执行 run_async 73 | """ 74 | 75 | def ret(_conf=None): 76 | if _conf: 77 | return _conf.func, _conf.run_async 78 | else: 79 | return None, None 80 | 81 | for conf in self[::-1]: 82 | 83 | if not conf.enabled or (conf.except_self and msg.chat == self.robot.self): 84 | return ret() 85 | 86 | if conf.msg_types and msg.type not in conf.msg_types: 87 | continue 88 | elif not conf.msg_types and msg.type == SYSTEM: 89 | continue 90 | 91 | if not conf.chats: 92 | return ret(conf) 93 | 94 | for chat in conf.chats: 95 | if chat == msg.chat or (isinstance(chat, type) and isinstance(msg.chat, chat)): 96 | return ret(conf) 97 | 98 | return ret() 99 | 100 | def get_config(self, func): 101 | """ 102 | 根据执行函数找到对应的配置 103 | 104 | :param func: 已注册的函数 105 | :return: 对应的配置 106 | """ 107 | for conf in self: 108 | if conf.func is func: 109 | return conf 110 | 111 | def _change_status(self, func, enabled): 112 | if func: 113 | self.get_config(func).enabled = enabled 114 | else: 115 | for conf in self: 116 | conf.enabled = enabled 117 | 118 | def enable(self, func=None): 119 | """ 120 | 开启指定函数的对应配置。若不指定函数,则开启所有已注册配置。 121 | 122 | :param func: 指定的函数 123 | """ 124 | self._change_status(func, True) 125 | 126 | def disable(self, func=None): 127 | """ 128 | 关闭指定函数的对应配置。若不指定函数,则关闭所有已注册配置。 129 | 130 | :param func: 指定的函数 131 | """ 132 | self._change_status(func, False) 133 | 134 | def _check_status(self, enabled): 135 | ret = list() 136 | for conf in self: 137 | if conf.enabled == enabled: 138 | ret.append(conf) 139 | return ret 140 | 141 | @property 142 | def enabled(self): 143 | """ 144 | 检查处于开启状态的配置 145 | 146 | :return: 处于开启状态的配置 147 | """ 148 | return self._check_status(True) 149 | 150 | @property 151 | def disabled(self): 152 | """ 153 | 检查处于关闭状态的配置 154 | 155 | :return: 处于关闭状态的配置 156 | """ 157 | return self._check_status(False) 158 | 159 | 160 | class Message(dict): 161 | """ 162 | 单条消息对象 163 | """ 164 | 165 | def __init__(self, raw, robot): 166 | super(Message, self).__init__(raw) 167 | 168 | self.robot = robot 169 | self.type = self.get('Type') 170 | 171 | self.is_at = self.get('isAt') 172 | self.file_name = self.get('FileName') 173 | self.img_height = self.get('ImgHeight') 174 | self.img_width = self.get('ImgWidth') 175 | self.play_length = self.get('PlayLength') 176 | self.url = self.get('Url') 177 | self.voice_length = self.get('VoiceLength') 178 | self.id = self.get('NewMsgId') 179 | 180 | self.text = None 181 | self.get_file = None 182 | self.create_time = None 183 | self.location = None 184 | self.card = None 185 | 186 | text = self.get('Text') 187 | if callable(text): 188 | self.get_file = text 189 | else: 190 | self.text = text 191 | 192 | create_time = self.get('CreateTime') 193 | if isinstance(create_time, int): 194 | self.create_time = datetime.datetime.fromtimestamp(create_time) 195 | 196 | if self.type == MAP: 197 | try: 198 | self.location = ETree.fromstring(self['OriContent']).find('location').attrib 199 | try: 200 | self.location['x'] = float(self.location['x']) 201 | self.location['y'] = float(self.location['y']) 202 | self.location['scale'] = int(self.location['scale']) 203 | self.location['maptype'] = int(self.location['maptype']) 204 | except (KeyError, ValueError): 205 | pass 206 | self.text = self.location.get('label') 207 | except (TypeError, KeyError, ValueError, ETree.ParseError): 208 | pass 209 | elif self.type in (CARD, FRIENDS): 210 | self.card = User(self.get('RecommendInfo')) 211 | self.text = self.card.get('Content') 212 | 213 | # 将 msg.chat.send* 方法绑定到 msg.reply*,例如 msg.chat.send_img => msg.reply_img 214 | for method in '', '_image', '_file', '_video', '_msg', '_raw_msg': 215 | setattr(self, 'reply' + method, getattr(self.chat, 'send' + method)) 216 | 217 | def __hash__(self): 218 | return hash((Message, self.id)) 219 | 220 | def __repr__(self): 221 | text = (str(self.text) or '').replace('\n', ' ') 222 | ret = '{0.chat.name}' 223 | if self.member: 224 | ret += ' -> {0.member.name}' 225 | ret += ': ' 226 | if self.text: 227 | ret += '{1} ' 228 | ret += '({0.type})' 229 | return ret.format(self, text) 230 | 231 | @property 232 | def raw(self): 233 | """原始数据""" 234 | return dict(self) 235 | 236 | @property 237 | def chat(self): 238 | """ 239 | 来自的聊天对象 240 | """ 241 | user_name = self.get('FromUserName') 242 | if user_name: 243 | for _chat in self.robot.chats(): 244 | if _chat.user_name == user_name: 245 | return _chat 246 | _chat = Chat(wrap_user_name(user_name)) 247 | _chat.robot = self.robot 248 | return _chat 249 | 250 | @property 251 | def member(self): 252 | """ 253 | 发送此消息的群聊成员 (若消息来自群聊) 254 | """ 255 | if isinstance(self.chat, Group): 256 | actual_user_name = self.get('ActualUserName') 257 | for _member in self.chat: 258 | if _member.user_name == actual_user_name: 259 | return _member 260 | return Member(dict(UserName=actual_user_name, NickName=self.get('ActualNickName')), self.chat) 261 | 262 | 263 | class Messages(list): 264 | """ 265 | 多条消息的合集,可用于记录或搜索 266 | """ 267 | 268 | def __init__(self, msg_list=None, robot=None, max_history=10000): 269 | if msg_list: 270 | super(Messages, self).__init__(msg_list) 271 | self.robot = robot 272 | self.max_history = max_history 273 | 274 | def __add__(self, other): 275 | return Chats(super(Messages, self).__add__(other)) 276 | 277 | def append(self, msg): 278 | del self[:-self.max_history + 1] 279 | return super(Messages, self).append(msg) 280 | 281 | def search(self, text=None, **attributes): 282 | """ 283 | 搜索消息 284 | 285 | :param text: 286 | :param attributes: 287 | :return: 288 | """ 289 | 290 | def match(msg): 291 | if not match_name(msg, text): 292 | return 293 | for attr, value in attributes.items(): 294 | if (getattr(msg, attr, None) or msg.get(attr)) != value: 295 | return 296 | return True 297 | 298 | if text: 299 | text = text.lower() 300 | return Chats(filter(match, self), self.robot) 301 | -------------------------------------------------------------------------------- /wxpy/mp.py: -------------------------------------------------------------------------------- 1 | from wxpy.user import User 2 | 3 | 4 | class MP(User): 5 | """ 6 | 公众号对象 7 | """ 8 | pass 9 | -------------------------------------------------------------------------------- /wxpy/response.py: -------------------------------------------------------------------------------- 1 | class Response(dict): 2 | """ 3 | | 从 itchat 获得的网络请求返回结果,绑定所属的 Robot 属性。 4 | | ret_code 不为 0 时会抛出 :class:`ResponseError` 异常 5 | """ 6 | 7 | def __init__(self, raw, robot): 8 | super(Response, self).__init__(raw) 9 | 10 | self.robot = robot 11 | 12 | self.base_response = self.get('BaseResponse', dict()) 13 | self.ret_code = self.base_response.get('Ret') 14 | self.err_msg = self.base_response.get('ErrMsg') 15 | 16 | if self.ret_code: 17 | raise ResponseError('code: {0.ret_code}; msg: {0.err_msg}'.format(self)) 18 | 19 | 20 | class ResponseError(Exception): 21 | """ 22 | 当 :class:`Response` 的返回值不为 0 时抛出的异常 23 | """ 24 | pass 25 | -------------------------------------------------------------------------------- /wxpy/user.py: -------------------------------------------------------------------------------- 1 | from wxpy.chat import Chat 2 | 3 | 4 | class User(Chat): 5 | """ 6 | 好友(:class:`Friend`)、群聊成员(:class:`Member`),和公众号(:class:`MP`) 的基础类 7 | """ 8 | 9 | def __init__(self, response): 10 | super(User, self).__init__(response) 11 | 12 | self.alias = response.get('Alias') 13 | self.display_name = response.get('DisplayName') 14 | self.remark_name = response.get('RemarkName') 15 | self.sex = response.get('Sex') 16 | self.province = response.get('Province') 17 | self.city = response.get('City') 18 | self.signature = response.get('Signature') 19 | 20 | def add(self, verify_content=''): 21 | return self.robot.add_friend(verify_content=verify_content) 22 | 23 | def accept(self, verify_content=''): 24 | return self.robot.accept_friend(verify_content=verify_content) 25 | 26 | @property 27 | def is_friend(self): 28 | """ 29 | 判断当前用户是否为好友关系 30 | 31 | :return: 若为好友关系则为 True,否则为 False 32 | """ 33 | if self.robot: 34 | return self in self.robot.friends() 35 | -------------------------------------------------------------------------------- /wxpy/util.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding: utf-8 3 | 4 | import logging 5 | from functools import wraps 6 | 7 | from wxpy.bot import Robot 8 | from wxpy.chats import Chats 9 | from wxpy.response import ResponseError 10 | from wxpy.user import User 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 | logging.warning('{0.__class__.__name__}: {0}'.format(e)) 24 | 25 | return wrapped 26 | 27 | 28 | def mutual_friends(*args): 29 | """ 30 | 找到多个微信用户的共同好友 31 | 32 | :param args: 每个参数为一个微信用户的机器人(Robot),或是聊天对象合集(Chats) 33 | :return: 共同的好友列表 34 | """ 35 | 36 | class FuzzyUser(User): 37 | def __init__(self, user): 38 | super(FuzzyUser, self).__init__(user) 39 | 40 | def __hash__(self): 41 | return hash((self.nick_name, self.province, self.city, self['AttrStatus'])) 42 | 43 | mutual = set() 44 | 45 | for arg in args: 46 | if isinstance(arg, Robot): 47 | friends = map(FuzzyUser, arg.friends) 48 | elif isinstance(arg, Chats): 49 | friends = map(FuzzyUser, arg) 50 | else: 51 | raise TypeError 52 | 53 | if mutual: 54 | mutual &= set(friends) 55 | else: 56 | mutual.update(friends) 57 | 58 | return Chats(mutual) 59 | 60 | 61 | def ensure_one(found): 62 | """ 63 | 确保列表中仅有一个项,并返回这个项,否则抛出 `ValueError` 异常 64 | 65 | :param found: 列表 66 | :return: 唯一项 67 | """ 68 | if not isinstance(found, list): 69 | raise TypeError('expected list, {} found'.format(type(found))) 70 | elif not found: 71 | raise ValueError('not found') 72 | elif len(found) > 1: 73 | raise ValueError('more than one found') 74 | else: 75 | return found[0] 76 | -------------------------------------------------------------------------------- /wxpy/utils/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/daimajia/wxpy/2b56fb67b9ccb072538fd778a27a8fef8d9c93e6/wxpy/utils/__init__.py -------------------------------------------------------------------------------- /wxpy/utils/constants.py: -------------------------------------------------------------------------------- 1 | # ---- Constants ---- 2 | 3 | MALE = 1 4 | FEMALE = 2 5 | 6 | # 文本 7 | TEXT = 'Text' 8 | # 位置 9 | MAP = 'Map' 10 | # 名片 11 | CARD = 'Card' 12 | # 提示 13 | NOTE = 'Note' 14 | # 分享 15 | SHARING = 'Sharing' 16 | # 图片 17 | PICTURE = 'Picture' 18 | # 语音 19 | RECORDING = 'Recording' 20 | # 文件 21 | ATTACHMENT = 'Attachment' 22 | # 视频 23 | VIDEO = 'Video' 24 | # 好友请求 25 | FRIENDS = 'Friends' 26 | # 系统 27 | SYSTEM = 'System' 28 | -------------------------------------------------------------------------------- /wxpy/utils/tools.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from functools import wraps 3 | 4 | import re 5 | 6 | 7 | def handle_response(to_class=None): 8 | """ 9 | 装饰器:检查从 itchat 返回的字典对象,并将其转化为指定类的实例 10 | 若返回值不为0,会抛出 ResponseError 异常 11 | 12 | :param to_class: 需转化成的类,若为None则不转换 13 | """ 14 | 15 | def decorator(func): 16 | @wraps(func) 17 | def wrapped(*args, **kwargs): 18 | from wxpy.chats import Chats 19 | from wxpy.group import Group 20 | from wxpy.groups import Groups 21 | from wxpy.response import Response 22 | 23 | ret = func(*args, **kwargs) 24 | 25 | if not ret: 26 | return 27 | 28 | if args: 29 | self = args[0] 30 | else: 31 | self = inspect.currentframe().f_back.f_locals.get('self') 32 | from wxpy.bot import Robot 33 | if isinstance(self, Robot): 34 | robot = self 35 | else: 36 | robot = getattr(self, 'robot', None) 37 | if not robot: 38 | raise ValueError('robot not found:m\nmethod: {}\nself: {}\nrobot: {}'.format( 39 | func, self, robot 40 | )) 41 | 42 | ret = list_or_single(Response, ret, robot) 43 | 44 | if to_class: 45 | ret = list_or_single(to_class, ret) 46 | 47 | if isinstance(ret, list): 48 | if to_class is Group: 49 | ret = Groups(ret) 50 | elif to_class: 51 | ret = Chats(ret) 52 | 53 | return ret 54 | 55 | return wrapped 56 | 57 | return decorator 58 | 59 | 60 | def ensure_list(x, except_false=True): 61 | """ 62 | 若传入的对象不为列表,则转化为列表 63 | 64 | :param x: 65 | :param except_false: None, False 等例外,会直接返回原值 66 | :return: 列表,或 None, False 等 67 | """ 68 | if x or not except_false: 69 | return x if isinstance(x, (list, tuple)) else [x] 70 | 71 | 72 | def match_name(chat, keywords): 73 | """ 74 | 检查一个 Chat 对象是否匹配所有名称关键词 (若关键词为空则直接认为匹配) 75 | 76 | :param chat: Chat 对象 77 | :param keywords: 名称关键词,可用空格分割 78 | :return: 匹配则返回 True,否则 False 79 | """ 80 | if keywords: 81 | if isinstance(keywords, str): 82 | keywords = re.split(r'\s+', keywords) 83 | keywords = list(map(lambda x: x.lower(), keywords)) 84 | for kw in keywords: 85 | for attr in 'nick_name', 'alias', 'remark_name', 'display_name': 86 | if kw in str(getattr(chat, attr, '')).lower(): 87 | break 88 | else: 89 | return False 90 | return True 91 | 92 | 93 | def list_or_single(func, i, *args, **kwargs): 94 | """ 95 | 将单个对象或列表中的每个项传入给定的函数,并返回单个结果或列表结果,类似于 map 函数 96 | 97 | :param func: 传入到的函数 98 | :param i: 列表或单个对象 99 | :param args: func 函数所需的 args 100 | :param kwargs: func 函数所需的 kwargs 101 | :return: 若传入的为列表,则以列表返回每个结果,反之为单个结果 102 | """ 103 | if isinstance(i, list): 104 | return list(map(lambda x: func(x, *args, **kwargs), i)) 105 | else: 106 | return func(i, *args, **kwargs) 107 | 108 | 109 | def wrap_user_name(user_or_users): 110 | """ 111 | 确保将用户转化为带有 UserName 键的用户字典 112 | 113 | :param user_or_users: 单个用户,或列表形式的多个用户 114 | :return: 单个用户字典,或列表形式的多个用户字典 115 | """ 116 | return list_or_single( 117 | lambda x: x if isinstance(x, dict) else {'UserName': user_or_users}, 118 | user_or_users 119 | ) 120 | 121 | 122 | def get_user_name(user_or_users): 123 | """ 124 | 确保将用户转化为 user_name 字串 125 | 126 | :param user_or_users: 单个用户,或列表形式的多个用户 127 | :return: 返回单个 user_name 字串,或列表形式的多个 user_name 字串 128 | """ 129 | return list_or_single( 130 | lambda x: x['UserName'] if isinstance(x, dict) else x, 131 | user_or_users 132 | ) 133 | --------------------------------------------------------------------------------