├── .gitignore ├── LICENSE ├── README.md ├── constant.py ├── log.py ├── logging.yaml ├── requirements.txt ├── robot.py ├── run.py ├── util.py └── wx.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### JetBrains template 3 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 4 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 5 | 6 | # User-specific stuff 7 | .idea 8 | .idea/**/workspace.xml 9 | .idea/**/tasks.xml 10 | .idea/**/usage.statistics.xml 11 | .idea/**/dictionaries 12 | .idea/**/shelf 13 | 14 | # Sensitive or high-churn files 15 | .idea/**/dataSources/ 16 | .idea/**/dataSources.ids 17 | .idea/**/dataSources.local.xml 18 | .idea/**/sqlDataSources.xml 19 | .idea/**/dynamic.xml 20 | .idea/**/uiDesigner.xml 21 | .idea/**/dbnavigator.xml 22 | 23 | # Gradle 24 | .idea/**/gradle.xml 25 | .idea/**/libraries 26 | 27 | # Gradle and Maven with auto-import 28 | # When using Gradle or Maven with auto-import, you should exclude module files, 29 | # since they will be recreated, and may cause churn. Uncomment if using 30 | # auto-import. 31 | # .idea/modules.xml 32 | # .idea/*.iml 33 | # .idea/modules 34 | 35 | # CMake 36 | cmake-build-*/ 37 | 38 | # Mongo Explorer plugin 39 | .idea/**/mongoSettings.xml 40 | 41 | # File-based project format 42 | *.iws 43 | 44 | # IntelliJ 45 | out/ 46 | 47 | # mpeltonen/sbt-idea plugin 48 | .idea_modules/ 49 | 50 | # JIRA plugin 51 | atlassian-ide-plugin.xml 52 | 53 | # Cursive Clojure plugin 54 | .idea/replstate.xml 55 | 56 | # Crashlytics plugin (for Android Studio and IntelliJ) 57 | com_crashlytics_export_strings.xml 58 | crashlytics.properties 59 | crashlytics-build.properties 60 | fabric.properties 61 | 62 | # Editor-based Rest Client 63 | .idea/httpRequests 64 | ### Python template 65 | # Byte-compiled / optimized / DLL files 66 | __pycache__/ 67 | *.py[cod] 68 | *$py.class 69 | 70 | # C extensions 71 | *.so 72 | 73 | # Distribution / packaging 74 | .Python 75 | build/ 76 | develop-eggs/ 77 | dist/ 78 | downloads/ 79 | eggs/ 80 | .eggs/ 81 | lib/ 82 | lib64/ 83 | parts/ 84 | sdist/ 85 | var/ 86 | wheels/ 87 | *.egg-info/ 88 | .installed.cfg 89 | *.egg 90 | MANIFEST 91 | 92 | # PyInstaller 93 | # Usually these files are written by a python script from a template 94 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 95 | *.manifest 96 | *.spec 97 | 98 | # Installer logs 99 | pip-log.txt 100 | pip-delete-this-directory.txt 101 | 102 | # Unit test / coverage reports 103 | htmlcov/ 104 | .tox/ 105 | .coverage 106 | .coverage.* 107 | .cache 108 | nosetests.xml 109 | coverage.xml 110 | *.cover 111 | .hypothesis/ 112 | .pytest_cache/ 113 | 114 | # Translations 115 | *.mo 116 | *.pot 117 | 118 | # Django stuff: 119 | *.log 120 | local_settings.py 121 | db.sqlite3 122 | 123 | # Flask stuff: 124 | instance/ 125 | .webassets-cache 126 | 127 | # Scrapy stuff: 128 | .scrapy 129 | 130 | # Sphinx documentation 131 | docs/_build/ 132 | 133 | # PyBuilder 134 | target/ 135 | 136 | # Jupyter Notebook 137 | .ipynb_checkpoints 138 | 139 | # pyenv 140 | .python-version 141 | 142 | # celery beat schedule file 143 | celerybeat-schedule 144 | 145 | # SageMath parsed files 146 | *.sage.py 147 | 148 | # Environments 149 | .env 150 | .venv 151 | env/ 152 | venv/ 153 | ENV/ 154 | env.bak/ 155 | venv.bak/ 156 | 157 | # Spyder project settings 158 | .spyderproject 159 | .spyproject 160 | 161 | # Rope project settings 162 | .ropeproject 163 | 164 | # mkdocs documentation 165 | /site 166 | 167 | # mypy 168 | .mypy_cache/ 169 | 170 | # log 171 | errors.log 172 | info.log -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 WZX 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## 微信自动聊天机器人 2 | 3 | - 对特定好友消息的自动回复 4 | - 加载自定义回复机器人 5 | - **若出现报错,心跳失败,长期未出现心跳成功或者无法接受消息的情况,请先在手机上退出电脑版微信登录等待一段时间并重新运行程序** 6 | 7 | ### 如何使用 8 | ```bash 9 | usage: run.py [-h] [--sync_frequency SYNC_FREQUENCY] 10 | [--target_name TARGET_NAME] [--open_mode {0,1,2}] 11 | [--inverse_mode INVERSE_MODE] [--robot ROBOT [ROBOT ...]] 12 | [--key KEY] 13 | 14 | wx聊天机器人 15 | 16 | optional arguments: 17 | -h, --help show this help message and exit 18 | --sync_frequency SYNC_FREQUENCY 19 | 心跳频率, 越大机器人回复的越慢 20 | --target_name TARGET_NAME 21 | 只对此人自动回复, 昵称(不是微信号)中不能包含表情, 默认为对所有人自动回复 22 | --open_mode {0,1,2} 二维码打开方式: 0.url; 1.console, 2.img 23 | --inverse_mode INVERSE_MODE 24 | 控制台打印二维码是否反色 25 | --robot ROBOT [ROBOT ...] 26 | 自定义回复机器人类名 27 | --key KEY 图灵机器人key 28 | 29 | ``` 30 | [图灵key申请网址](http://www.tuling123.com/) 31 | 32 | 33 | 34 | - `open_mode`默认为0,在控制台中打印二维码的url 35 | - `open_mode`若为1,则在控制台打印二维码,需要在`inverse_mode`中指定true或者false,这是因为有的控制台是黑底白字的,打印的二维码需要反色 36 | - `open_mode`若为2,则自动调用系统默认图片查看器打开二维码 37 | 38 | ### 自定义机器人 39 | 已经实现了调用[ChatterBot](https://github.com/gunthercox/ChatterBot) 的本地机器人(效果较差)和调用图灵api的机器人(效果较好但有每天次数限制)。 40 | 41 | 如要实现自己的机器人,需要在[robot.py](robot.py)下继承`Robot` 42 | ```python 43 | # robot.py 44 | class ExampleRobot(Robot): 45 | def filter(self, msg: str) -> bool: 46 | return True 47 | 48 | def get_reply(self, msg: str) -> str: 49 | return msg.replace('吗', '').replace('?', '!') 50 | ``` 51 | 运行时通过`--robot`选项指定机器人类名。注册多个机器人会触发多个机器人的回复信息 52 | ```bash 53 | python -m run --robot ExampleRobot 54 | ``` 55 | 56 | ### 构建环境 57 | ```bash 58 | pip install -r requirements.txt 59 | python -m spacy download en 60 | ``` 61 | 62 | ### 运行 63 | ```bash 64 | python run.py --target_name xx 65 | ``` 66 | -------------------------------------------------------------------------------- /constant.py: -------------------------------------------------------------------------------- 1 | # 模拟请求头 2 | HEADERS = {'Host': 'wx.qq.com', 'Referer': 'https://wx.qq.com/?&lang=zh_CN', 'Origin': 'https://wx.qq.com', 3 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3534.4 Safari/537.36'} 4 | 5 | UUID_URL = 'https://login.wx.qq.com/jslogin?appid=wx782c26e4c19acffb' 6 | 7 | # 获取二维码 8 | QR_URL = 'https://login.weixin.qq.com/qrcode/{uuid}' 9 | 10 | LOGIN_URL = 'https://login.wx.qq.com/cgi-bin/mmwebwx-bin/login?loginicon=true&uuid={uuid}&tip=0' 11 | 12 | KEY_URL = 'https://wx.qq.com/cgi-bin/mmwebwx-bin/webwxinit?pass_ticket={pass_ticket}&lang=ch_ZN' 13 | 14 | # 接收信息 15 | REC_URL = 'https://wx.qq.com/cgi-bin/mmwebwx-bin/webwxsync?sid={sid}&skey={skey}&lang=zh_CN&pass_ticket={pass_ticket}' 16 | 17 | # 心跳包 18 | SYNC_URL = 'https://webpush.wx.qq.com/cgi-bin/mmwebwx-bin/synccheck?r={r}&skey={skey}&sid={sid}&uin={uin}&deviceid={deviceid}&synckey={synckey}&_={_}' 19 | 20 | # 开启通知 21 | N_URL = 'https://wx.qq.com/cgi-bin/mmwebwx-bin/webwxstatusnotify?lang=zh_CN&pass_ticket={pass_ticket}' 22 | 23 | # send 24 | SEND_URL = 'https://wx.qq.com/cgi-bin/mmwebwx-bin/webwxsendmsg?lang=zh_CN&pass_ticket={pass_ticket}' 25 | 26 | # 获取联系人 27 | CONTACT_URL = 'https://wx.qq.com/cgi-bin/mmwebwx-bin/webwxgetcontact?pass_ticket={pass_ticket}&r={r}&seq=0&skey={skey}' 28 | 29 | # 图灵机器人接口 30 | TULING_URL = 'http://www.tuling123.com/openapi/api?key={key}&info={info}' 31 | -------------------------------------------------------------------------------- /log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import logging.config 4 | import yaml 5 | 6 | 7 | def setup_logging(path='logging.yaml', level=logging.INFO): 8 | if os.path.exists(path): 9 | with open(path, 'rt') as f: 10 | config = yaml.safe_load(f.read()) 11 | logging.config.dictConfig(config) 12 | else: 13 | logging.basicConfig(level=level) 14 | -------------------------------------------------------------------------------- /logging.yaml: -------------------------------------------------------------------------------- 1 | version: 1.0 2 | disable_existing_loggers: False 3 | formatters: 4 | simple: 5 | format: "%(asctime)s - %(name)s - %(levelname)s - %(message)s" 6 | 7 | handlers: 8 | console: 9 | class: logging.StreamHandler 10 | level: DEBUG 11 | formatter: simple 12 | stream: ext://sys.stdout 13 | 14 | info_file_handler: 15 | class: logging.handlers.RotatingFileHandler 16 | level: INFO 17 | formatter: simple 18 | filename: info.log 19 | maxBytes: 10485760 # 10MB 20 | backupCount: 20 21 | encoding: utf8 22 | 23 | error_file_handler: 24 | class: logging.handlers.RotatingFileHandler 25 | level: ERROR 26 | formatter: simple 27 | filename: errors.log 28 | maxBytes: 10485760 # 10MB 29 | backupCount: 20 30 | encoding: utf8 31 | 32 | loggers: 33 | root: 34 | level: DEBUG 35 | handlers: [console, info_file_handler, error_file_handler] 36 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests==2.25.1 2 | ChatterBot==1.0.8 3 | chatterbot-corpus==1.2.0 4 | pyquery==1.4.3 5 | Pillow==8.0.1 6 | spacy==2.3.5 -------------------------------------------------------------------------------- /robot.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | import logging 4 | import os 5 | import requests 6 | import json 7 | from constant import TULING_URL 8 | from abc import ABCMeta, abstractmethod 9 | from chatterbot import ChatBot 10 | from chatterbot.trainers import ChatterBotCorpusTrainer 11 | 12 | 13 | class Robot(metaclass=ABCMeta): 14 | @abstractmethod 15 | def filter(self, msg: str) -> bool: 16 | """ 17 | 过滤需要回复的信息 18 | """ 19 | pass 20 | 21 | @abstractmethod 22 | def get_reply(self, msg: str) -> str: 23 | """ 24 | 回复信息 25 | """ 26 | pass 27 | 28 | def reply(self, msg: str) -> str: 29 | """ 30 | 回复信息 31 | """ 32 | if self.filter(msg): 33 | return self.get_reply(msg) 34 | 35 | 36 | class TuLingRobot(Robot): 37 | def __init__(self, key: str): 38 | if key is None: 39 | raise RuntimeError("未指定图灵机器人key") 40 | self.__key = key 41 | 42 | def filter(self, msg: str) -> bool: 43 | return True 44 | 45 | def get_reply(self, msg: str) -> str: 46 | res = requests.get(TULING_URL.format(key=self.__key, info=msg)) 47 | res.encoding = 'utf-8' 48 | jd = json.loads(res.text) 49 | return jd['text'] 50 | 51 | 52 | class LocalRobot(Robot): 53 | def __init__(self): 54 | train = not os.path.exists('db.sqlite3') 55 | self.__logger = logging.getLogger("root") 56 | self.__chat_bot = ChatBot("robot") 57 | if train: 58 | self.__logger.debug("开始训练LocalRobot") 59 | trainer = ChatterBotCorpusTrainer(self.__chat_bot) 60 | trainer.train("chatterbot.corpus.chinese") 61 | 62 | def filter(self, msg: str) -> bool: 63 | return True 64 | 65 | def get_reply(self, msg: str) -> str: 66 | return str(self.__chat_bot.get_response(msg)) 67 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | from argparse import ArgumentParser 4 | from log import setup_logging 5 | from wx import Wx 6 | import robot 7 | 8 | if __name__ == '__main__': 9 | # 解析参数 10 | parser = ArgumentParser(description='wx聊天机器人') 11 | parser.add_argument('--sync_frequency', type=int, default=2, help='心跳频率, 越大机器人回复的越慢') 12 | parser.add_argument('--target_name', type=str, default=None, help="只对此人自动回复, 昵称(不是微信号)中不能包含表情, 默认为对所有人自动回复") 13 | parser.add_argument('--open_mode', type=int, choices=[0, 1, 2], default=0, help='二维码打开方式: 0.url; 1.console, 2.img') 14 | parser.add_argument('--inverse_mode', type=bool, default=False, help='控制台打印二维码是否反色') 15 | parser.add_argument('--robot', nargs='+', default=['LocalRobot'], help='自定义回复机器人类名') 16 | parser.add_argument('--key', type=str, default=None, help='图灵机器人key') 17 | args = vars(parser.parse_args()) 18 | 19 | setup_logging() 20 | wx = Wx() 21 | # 动态注册回复机器人 22 | robots: [] = args['robot'] 23 | for robot_name in robots: 24 | robot_class = getattr(robot, robot_name) 25 | if robot_name == 'TuLingRobot': 26 | robot_obj = robot_class(args['key']) 27 | else: 28 | robot_obj = robot_class() 29 | wx.register(robot_obj) 30 | url = wx.login(args['open_mode'], args['inverse_mode']) 31 | wx.fetch_user_info(url) 32 | wx.fetch_contact() 33 | wx.open_notify() 34 | wx.run(args['target_name'], args['sync_frequency']) 35 | -------------------------------------------------------------------------------- /util.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | import time 4 | from io import BytesIO 5 | from PIL import Image 6 | 7 | 8 | def get_image(source, width=40, height=40, mood=False): 9 | """ 10 | 二进制二维码转化为字符串 11 | :param source: 源数据 12 | :param width: 缩放图片大小 13 | :param height: 缩放图片大小 14 | :param mood: false->正常 true->反色 15 | :return: 16 | """ 17 | 18 | data = BytesIO() 19 | data.write(source) 20 | image = Image.open(data) 21 | image = image.resize((width, height), Image.NEAREST) 22 | code = '' 23 | 24 | for y in range(height): 25 | for x in range(width): 26 | pix = image.getpixel((x, y)) 27 | if mood: 28 | if pix < 10: 29 | code += '██' 30 | if pix > 200: 31 | code += ' ' 32 | else: 33 | if pix > 200: 34 | code += '██' 35 | if pix < 10: 36 | code += ' ' 37 | code += '\n' 38 | 39 | return code 40 | 41 | 42 | def get_time_stamp() -> int: 43 | """ 44 | 获得时间戳13位 45 | :return: 时间戳 46 | """ 47 | t = time.time() 48 | return int(round(t * 1000)) 49 | -------------------------------------------------------------------------------- /wx.py: -------------------------------------------------------------------------------- 1 | # coding=utf-8 2 | 3 | import json 4 | import logging 5 | import random 6 | import requests 7 | import re 8 | from pyquery import PyQuery as pq 9 | from typing import List, Tuple 10 | import robot 11 | from constant import * 12 | from util import * 13 | 14 | 15 | class Wx(object): 16 | 17 | def __init__(self): 18 | # 保存cookie的会话 19 | self.__req = requests.Session() 20 | # 日志 21 | self.__logger = logging.getLogger("root") 22 | # 用户信息 23 | self.__info = {} 24 | ''' 25 | skey 26 | sid 27 | uin 28 | pass_ticket 29 | from_user_name 本机用户id 30 | BaseRequest 31 | SyncKey 字典格式 32 | synkey 心跳包请求格式 33 | ''' 34 | # 联系人信息 35 | self.__id2name = {} 36 | self.__name2id = {} 37 | # 回复机器人 38 | self.__robots = [] 39 | 40 | def register(self, robo: robot) -> None: 41 | """ 42 | 注册机器人 43 | """ 44 | self.__robots.append(robo) 45 | self.__logger.debug("register " + robo.__class__.__name__) 46 | 47 | def login(self, open_mode: int, inverse_mode: bool) -> str: 48 | """ 49 | 模拟登录 50 | :param open_mode: 二维码打开模式 51 | :param inverse_mode: 二维码打印模式 52 | :return url 53 | """ 54 | 55 | # 获取二维码地址 56 | res = self.__req.get(url=UUID_URL, headers=HEADERS) 57 | res.raise_for_status() 58 | 59 | text = res.content.decode('utf-8') 60 | pattern = re.compile('"(.+)"') 61 | uuid = re.findall(pattern, text)[0] 62 | if not uuid: 63 | raise ValueError(text) 64 | 65 | res = self.__req.get(url=QR_URL.format(uuid=uuid), headers=HEADERS) 66 | res.raise_for_status() 67 | 68 | if open_mode == 2: 69 | im = Image.open(res.content) 70 | im.show() 71 | elif open_mode == 1: 72 | print(get_image(res.content, mood=inverse_mode)) 73 | elif open_mode == 0: 74 | print('请打开二维码连接:' + QR_URL.format(uuid=uuid)) 75 | 76 | self.__logger.debug('登录二维码加载成功') 77 | 78 | # 模拟登录 79 | while True: 80 | res = self.__req.get(LOGIN_URL.format(uuid=uuid), headers=HEADERS) 81 | res.raise_for_status() 82 | 83 | text = res.content.decode('utf-8') 84 | if 'redirect_uri' in text: 85 | pattern = re.compile('redirect_uri="(.+)"') 86 | url = re.findall(pattern, text)[0] 87 | if url: 88 | self.__logger.info('登录成功') 89 | return url 90 | else: 91 | raise ValueError(text) 92 | elif 'userAvatar' in text: 93 | self.__logger.debug('用户已扫码') 94 | time.sleep(5) 95 | elif '400' in text: 96 | raise RuntimeError('二维码已过期,请重启') 97 | 98 | def fetch_user_info(self, url: str) -> None: 99 | """ 100 | 获取并保存用户信息 101 | :param url: 102 | """ 103 | 104 | # 获得信息 105 | res = self.__req.get(url + '&fun=new&version=v2&lang=zh_CN', headers=HEADERS) 106 | res.raise_for_status() 107 | res.encoding = 'utf-8' 108 | 109 | doc = pq(res.text) 110 | skey = doc('skey').text() 111 | sid = doc('wxsid').text() 112 | uin = doc('wxuin').text() 113 | pass_ticket = doc('pass_ticket').text() 114 | 115 | if not (skey and sid and uin and pass_ticket): 116 | raise ValueError(res.text) 117 | 118 | # DeviceID巨坑,其实是随机生成的,但要不断变化 119 | base_request = {'uin': uin, 'sid': sid, 'skey': skey, 'DeviceID': 'e' + repr(random.random())[2:17]} 120 | data = {'BaseRequest': base_request} 121 | # post的是json格式 122 | dump_json_data = json.dumps(data) 123 | 124 | res = self.__req.post(KEY_URL.format(pass_ticket=pass_ticket), headers=HEADERS, data=dump_json_data) 125 | res.raise_for_status() 126 | 127 | # 获取key 128 | json_res = json.loads(res.content.decode('utf-8')) 129 | sync_key_all = json_res['SyncKey'] 130 | 131 | if not sync_key_all: 132 | raise ValueError(json_res['SyncKey']) 133 | 134 | sync_key = '' 135 | for key in sync_key_all['List']: 136 | sync_key += str(key['Key']) + '_' + str(key['Val']) + '|' 137 | sync_key = sync_key[:-1] 138 | 139 | from_user_name = json_res['User']['UserName'] 140 | 141 | self.__info['skey'] = skey 142 | self.__info['sid'] = sid 143 | self.__info['uin'] = uin 144 | self.__info['pass_ticket'] = pass_ticket 145 | self.__info['from_user_name'] = from_user_name 146 | self.__info['BaseRequest'] = base_request 147 | self.__info['SyncKey'] = sync_key_all 148 | self.__info['synckey'] = sync_key 149 | 150 | self.__logger.debug('基本信息获取完毕') 151 | 152 | def open_notify(self) -> None: 153 | """ 154 | 开启通知 155 | """ 156 | 157 | data = {'BaseRequest': self.__info['BaseRequest'], 'Code': 3, 'FromUserName': self.__info['from_user_name'], 158 | 'ToUserName': self.__info['from_user_name'], 'ClientMsgId': get_time_stamp()} 159 | json_data = json.dumps(data) 160 | res = self.__req.post(N_URL.format(pass_ticket=self.__info['pass_ticket']), headers=HEADERS, data=json_data) 161 | res.raise_for_status() 162 | json_data = json.loads(res.content.decode('utf-8')) 163 | ret = json_data['BaseResponse']['Ret'] 164 | if ret == 0: 165 | self.__logger.info('开启通知成功') 166 | else: 167 | raise RuntimeError('开启通知失败') 168 | 169 | def __sync(self) -> str: 170 | """ 171 | 发送心跳包,需要循环调用以维持连接 172 | :return: ret_code 173 | """ 174 | 175 | try: 176 | res = self.__req.get( 177 | SYNC_URL.format(r=get_time_stamp(), skey=self.__info['skey'], sid=self.__info['sid'], 178 | uin=self.__info['uin'], 179 | deviceid='e' + repr(random.random())[2:17], 180 | synckey=self.__info['synckey'], headers=HEADERS, _=get_time_stamp()), timeout=60) 181 | res.raise_for_status() 182 | 183 | text = res.content.decode('utf-8') 184 | pattern = re.compile(r'retcode:"(\d+)",selector:"(\d)"') 185 | ret_code, selector = re.findall(pattern, text)[0] 186 | if ret_code == '0': 187 | self.__logger.debug('心跳成功') 188 | return selector 189 | else: 190 | self.__logger.error('心跳失败') 191 | except requests.exceptions.RequestException: 192 | self.__logger.exception('心跳失败') 193 | 194 | def __get_message(self) -> List[Tuple[str, str]]: 195 | """ 196 | 接收消息并更新synckey 197 | :return: 消息列表 198 | """ 199 | 200 | data = {'BaseRequest': self.__info['BaseRequest'], 'rr': ~int(time.time()), 'SyncKey': self.__info['SyncKey']} 201 | dump_json_data = json.dumps(data) 202 | res = self.__req.post( 203 | REC_URL.format(sid=self.__info['sid'], skey=self.__info['skey'], pass_ticket=self.__info['pass_ticket']), 204 | data=dump_json_data, 205 | headers=HEADERS) 206 | res.raise_for_status() 207 | json_data = json.loads(res.content.decode('utf-8')) 208 | 209 | # 更新synckey 210 | keys = json_data['SyncKey']['List'] 211 | synckey = '' 212 | for key in keys: 213 | synckey += str(key['Key']) + '_' + str(key['Val']) + '|' 214 | synckey = synckey[:-1] 215 | self.__info['synckey'] = synckey 216 | self.__info['SyncKey'] = json_data['SyncKey'] 217 | 218 | # 获取信息 219 | add_msg_count = int(json_data['AddMsgCount']) 220 | add_msg_list = json_data['AddMsgList'] 221 | if add_msg_count != 0: 222 | msg_list: List[Tuple[str, str]] = [] 223 | for msg in add_msg_list: 224 | from_user_name = msg['FromUserName'] 225 | content = msg['Content'] 226 | if content == '' and from_user_name == self.__info['from_user_name']: 227 | self.__logger.debug('检测到微信其他端有空操作') 228 | 229 | elif from_user_name == self.__info['from_user_name']: 230 | self.__logger.info('微信其他端发出消息: {message}'.format(message=content)) 231 | 232 | else: 233 | msg_list.append((from_user_name, content)) 234 | self.__logger.info('接收到' + str(len(msg_list)) + '个消息') 235 | return msg_list 236 | 237 | def __send(self, message: str, to_user_name: str) -> None: 238 | """ 239 | 发送消息 240 | :param message: 241 | :param to_user_name: 242 | """ 243 | 244 | if message is None: 245 | return 246 | 247 | # 这里有个坑,ClientMsgId为,时间戳左移4位随后补上4位随机数 248 | msg = {'ClientMsgId': int(str(get_time_stamp()) + repr(random.random())[2:6]), 'Content': message, 249 | 'FromUserName': self.__info['from_user_name'], 250 | 'LocalID': int(str(get_time_stamp()) + repr(random.random())[2:6]), 251 | 'ToUserName': to_user_name, 'Type': 1} 252 | data = {'BaseRequest': self.__info['BaseRequest'], 'Msg': msg, 'Scene': 0} 253 | 254 | # 要这样设置,不然会乱码 255 | json_dump_data = json.dumps(data, ensure_ascii=False) 256 | temp_headers = HEADERS 257 | temp_headers['Content-Type'] = 'application/json;charset=UTF-8' 258 | res = self.__req.post(SEND_URL, data=json_dump_data.encode('utf-8'), headers=temp_headers) 259 | res.raise_for_status() 260 | 261 | json_data = json.loads(res.content.decode('utf-8')) 262 | if json_data['BaseResponse']['Ret'] == 0: 263 | self.__logger.info('发送给 {name}: {message}'.format(name=self.__id2name.get(to_user_name, to_user_name), 264 | message=message)) 265 | else: 266 | self.__logger.error( 267 | '发送给 {name}: {message} 失败'.format(name=self.__id2name.get(to_user_name, to_user_name), 268 | message=message)) 269 | 270 | def fetch_contact(self): 271 | """ 272 | 获取并缓存联系人 273 | """ 274 | 275 | # 昵称中表情会乱码 276 | res = self.__req.get( 277 | CONTACT_URL.format(pass_ticket=self.__info['pass_ticket'], r=get_time_stamp(), skey=self.__info['skey']), 278 | headers=HEADERS) 279 | res.raise_for_status() 280 | 281 | json_data = json.loads(res.content.decode('utf-8')) 282 | member_list = json_data['MemberList'] 283 | 284 | if not member_list: 285 | raise RuntimeError(res.text) 286 | 287 | for member in member_list: 288 | self.__id2name[member['UserName']] = member['NickName'] 289 | self.__name2id[member['NickName']] = member['UserName'] 290 | 291 | def run(self, target_name: str, sync_frequency: int): 292 | try: 293 | target_id = '' 294 | 295 | # 找到昵称对应id 296 | if target_name is None: 297 | target_id = 'ALL' 298 | elif target_name in self.__name2id.keys(): 299 | target_id = self.__name2id[target_name] 300 | 301 | if target_id: 302 | self.__logger.debug('找到目标' + str(target_id)) 303 | while True: 304 | ret_code = self.__sync() 305 | if ret_code == '2': 306 | # 收到消息 307 | msg_list = self.__get_message() 308 | if msg_list: 309 | for user_id, content in msg_list: 310 | self.__logger.info('收到信息 ' + self.__id2name.get(user_id, user_id) + ':' + content) 311 | if target_id in ['ALL', user_id]: 312 | # 调用所有机器人 313 | for robo in self.__robots: 314 | self.__send(robo.reply(content), user_id) 315 | 316 | time.sleep(sync_frequency) 317 | 318 | else: 319 | raise RuntimeError('找不到用户:' + target_name) 320 | 321 | except Exception as e: 322 | self.__logger.exception(e) 323 | --------------------------------------------------------------------------------