之前公司有一个需求,需要搭建一个微信机器人,查阅了相关资料,整理了一些Demo之后,我大概实现了一个python的微信机器人聊天程序,这里就把经验拿出来分享下。
基本原理
首先你要明白,微信并没有给你提供一套接口,一套文档,让你开发聊天机器人。如果你想实现一个机器人,能够被拉到群里,与群里的人聊天,就必须用点hack的办法。
这个原理就是web版本的微信,想想你登录web版的微信,在浏览器输入地址后,先会给你返回一个二维码。然后你用你的手机扫描了此二维码,接着就登录成功了。
这个扫二维码登录的过程,其实就是一个授权的过程,即你授权给web界面,使得可以通过web页面,用http的方式与微信服务器建立交互。所以微信机器人的实现方式就是基于此。你创建一个账号,然后这个账号就是微信机器人,你通过扫码的方式登录,使得可以通过http的方式与微信服务器建立交互。就这样,我们的服务托管了你的机器人账户,从而与群里的人进行交流,解析他们的聊天记录,匹配关键字,生成相应的回复信息。
交互流程
首先想想和微信服务器建立交互的时序图。

所以当我们要用一个服务来托管你的微信机器人,就需要维护一个与微信服务器通信的HTTP Session。

这里要注意的是,http是上下文无关的,所以你与微信服务器的交互必须要通过cookie建立上下文,还有http协议是一问一答的形式,所以服务器并不能主动推送数据给客户端,所以你只能使用轮训的方式,从微信服务器获取消息信息。
基于python tornado服务的实现思路
网上有一个现成的库,提供tornado异步客户端的Session方式,其实就是使用RequestsCookieJar,这个库来保存Cookie。
我通过这个库来包装了一个Session,方便我调用get和post。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
| from httpclient_session import Session
class RequestSession(object): """ 一个能维护异步请求的session,传递cookie. """ def __init__(self): self._session = Session()
@tornado.gen.coroutine def retry_request_get(self, url, timeout=None): """ 此请求如果失败,将会重试,直到成功为止(最多三次) :return: """ try: headers = HTTPHeaders({'User-Agent': 'Mozilla/5.0 (X11; Linux i686; U;) Gecko/20070322 Kazehakase/0.4.5'}) request = HTTPRequest( url=url, headers=headers, connect_timeout=timeout, request_timeout=timeout ) response = yield self._session.fetch(request) except Exception: logger.error('request error: %s' % url, exc_info=True) raise tornado.gen.Return((False, None)) else: raise tornado.gen.Return((True, response.body))
@tornado.gen.coroutine def retry_request_post(self, url, body, timeout=None): """ 此请求如果失败,将会重试,直到成功为止(最多三次) :return: """ try: headers = HTTPHeaders({'User-Agent': 'Mozilla/5.0 (X11; Linux i686; U;) Gecko/20070322 Kazehakase/0.4.5'}) request = HTTPRequest( url=url, body=body, method="POST", headers=headers, connect_timeout=timeout, request_timeout=timeout ) response = yield self._session.fetch(request) except Exception: logger.error('request error: %s' % url, exc_info=True) raise tornado.gen.Return((False, None)) else: raise tornado.gen.Return((True, response.body))
|
请求加入的头部参数,是为了伪装成浏览器,那么对于这个例子,我创建的每一个机器人类,都会维护一个自己的RequestSession。
交互过程
先需要从一个get请求获取uuid。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27
| @tornado.gen.coroutine def get_uuid(self): """ 获取微信web登录验证所需的uuuid :return: """ url = https://login.weixin.qq.com/jslogin params = { 'appid': 'wx782c26e4c19acffb', 'fun': 'new', 'lang': 'zh_CN', '_': int(time.time()) * 1000 + random.randint(1, 999), } url = "%s?%s" % (url, urllib.urlencode(params)) logger.debug("fetch uuid: %s" % url) ok, body = yield self._session.retry_request_get(url) regx = r'window.QRLogin.code = (\d+); window.QRLogin.uuid = "(\S+?)"'
if ok: pm = re.search(regx, body) if pm: code = pm.group(1) uuid = pm.group(2) if code == "200": self._uuid = uuid raise tornado.gen.Return(True) raise tornado.gen.Return(False)
|
然后通过这个uuid,配上一个url,来生成一个用于登录的微信二维码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| def gen_login_qrcode_png(self): """ 生成二维码登录图片,返回一个io流对象 :return: """ url = 'https://login.weixin.qq.com/l/' logger.debug("login url: %s" % url) qr = qrcode.QRCode( version=1, error_correction=qrcode.constants.ERROR_CORRECT_L, box_size=10, border=4, ) qr.add_data(url) qr.make(fit=True)
out = BytesIO() qr_img = qr.make_image() qr_img.save(out, 'PNG') return out
|
之后你的服务就需要一直不停的轮训微信服务器,来知道客户登录的状态:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55
| @tornado.gen.coroutine def wait_for_login(self): """ 等待用户登录 :return: """ TIP_WAIT_FOR_SCAN_QR_CODE = 1 TIP_WAIT_CONFIRM_LOGIN = 0 MAX_RETRY_TIMES = 10 TRY_LATER_SECS = 1 UNKONWN = 'unkonwn' SUCCESS = '200' SCANED = '201' TIMEOUT = '408'
tip = TIP_WAIT_FOR_SCAN_QR_CODE retry_time = MAX_RETRY_TIMES code = UNKONWN
while retry_time > 0: url = "%s?tip=%s&uuid=%s&_=%s" % (https://login.weixin.qq.com/cgi-bin/mmwebwx-bin/login, tip, self._uuid, int(time.time())) logger.debug("get login status url: %s" % url) ok, body = yield self._session.retry_request_get(url) logger.debug("get login status response: %s" % body) if ok: param = re.search(r'window.code=(\d+);', body) code = param.group(1) if code == SCANED: tip = TIP_WAIT_CONFIRM_LOGIN elif code == SUCCESS: param = re.search(r'window.redirect_uri="(\S+?)";', body) self._redirect_uri = param.group(1) + '&fun=new' self._base_uri = self._redirect_uri[:self._redirect_uri.rfind('/')] temp_host = self._base_uri[8:] self._base_host = temp_host[:temp_host.find("/")] logger.info(u"微信登录成功") raise tornado.gen.Return(True) elif code == TIMEOUT: tip = TIP_WAIT_FOR_SCAN_QR_CODE retry_time -= 1 yield tornado.gen.sleep(TRY_LATER_SECS) else: tip = TIP_WAIT_FOR_SCAN_QR_CODE retry_time -= 1 yield tornado.gen.sleep(TRY_LATER_SECS)
raise tornado.gen.Return(code == SUCCESS)
|
之后就要根据之前获取到的redirect_uri,来获取一些,skey,sid,in,pass_ticket等字段,这些参数每次与微信服务器交互都要用到。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101
| @tornado.gen.coroutine def login(self): """ 登录并获取相关信息 :return: """ if len(self._redirect_uri) < 4: logger.error("Login failed due to network problem, please try again.") raise tornado.gen.Return(False)
ok, body = yield self._session.retry_request_get(self._redirect_uri) logger.debug("login info: %s" % body) if ok: doc = xml.dom.minidom.parseString(body) root = doc.documentElement
for node in root.childNodes: if node.nodeName == 'skey': self._skey = node.childNodes[0].data elif node.nodeName == 'wxsid': self._sid = node.childNodes[0].data elif node.nodeName == 'wxuin': self._uin = node.childNodes[0].data elif node.nodeName == 'pass_ticket': self._pass_ticket = node.childNodes[0].data
if '' in (self._skey, self._sid, self._uin, self._pass_ticket): raise tornado.gen.Return(False)
self._base_request["Uin"] = self._uin self._base_request["Sid"] = self._sid self._base_request["Skey"] = self._skey self._base_request["DeviceID"] = self._device_id
raise tornado.gen.Return(True) else: raise tornado.gen.Return(False)
@tornado.gen.coroutine def main(self): """ 机器人的运行主逻辑 TODO: 这里的主逻辑可能不对 :return: """ if self._main_lock == False: self._main_lock = True if self.status == BaseRobot.STATUS_LIVE: code, msg = yield self._wxClient.dispatch_msg() if code == WxClient.WX_RETURN_CODE_LOGINOUT or code == WxClient.WX_RETURN_CODE_RELOGINOTHER: self.status = BaseRobot.STATUS_DEAD logger.info("robot stop...")
self.send_message() else: logger.info("robot dead.") self._main_lock = False
@tornado.gen.coroutine def dispatch_msg(self): """ 处理消息(根据不同消息类型,分类处理) :return: """ retcode, selector = yield self.sync_check() if retcode == WxClient.WX_RETURN_CODE_LOGINOUT: raise tornado.gen.Return((WxClient.WX_RETURN_CODE_LOGINOUT, None)) elif retcode == WxClient.WX_RETURN_CODE_RELOGINOTHER: raise tornado.gen.Return((WxClient.WX_RETURN_CODE_RELOGINOTHER, None)) elif retcode == WxClient.WX_RETURN_CODE_MESSAGE: if selector == WxClient.SELECTOR_NEW_MESSAGE: r = yield self.sync() if r is not None: self.handle_msg(r) raise tornado.gen.Return((WxClient.WX_RETURN_CODE_MESSAGE, None)) elif selector == WxClient.SELECTOR_UNKNOWN: r = yield self.sync() if r is not None: self.handle_msg(r) raise tornado.gen.Return((WxClient.WX_RETURN_CODE_MESSAGE, None)) elif selector == WxClient.SELECTOR_ADDRESS_LIST_UPDATE: r = yield self.sync() if r is not None: raise tornado.gen.Return((WxClient.WX_RETURN_CODE_MESSAGE, None)) elif selector == WxClient.SELECTOR_MAYBE_RED_PACKET: r = yield self.sync() if r is not None: raise tornado.gen.Return((WxClient.WX_RETURN_CODE_MESSAGE, None)) elif selector == WxClient.SELECTOR_USE_PHONE_OPERATE: r = yield self.sync() if r is not None: self.handle_msg(r) raise tornado.gen.Return((WxClient.WX_RETURN_CODE_MESSAGE, None)) elif selector == WxClient.SELECTOR_ZERO: pass else: logger.warning("unknown retcode, selector: %s, %s" % (retcode, selector)) raise tornado.gen.Return((WxClient.WX_RETURN_CODE_UNKNOWN, None))
logger.warning("unknown sync check: %s, %s" % (retcode, selector)) raise tornado.gen.Return((WxClient.WX_RETURN_CODE_UNKNOWN, None))
|
之后的代码只需要从服务器不停的轮训,获取数据,在根据消息类型,做不同的处理就行了。
注意事项
首先这个服务其实写起来比我想象的要复杂,因为在服务器维护每个机器人的session,然后不停的轮训这件事,一个服务器还好说,如果部署到多台服务器,那就会麻烦很多,比如你的session一定要入库的,否则一重启什么都没了,那么如果入库,那由哪个服务器的哪个线程用来与服务器建立通信呢,所以这里我只是做了单机部署,因为这篇博客的意图,其实通过微信机器人程序,总结HTTP的一些知识。
还有就是这个服务可能会很不稳定,因为我并没有做过测试来验证,web版的微信到底能登录多长时间。所以它有可能在登录一段时间后就掉线了,而且这种“破解”类的程序,微信随时都有可能封你,尤其你使用了类似“加好友”这样的接口。
不仅仅是每次登录获取的uuid和之后的各种token不一样,实际上你每次重新登录,用户的id,和组的id,甚至你自己的id,都是重新生成的。
微信web是单点登录的,所以你在另一个web页面登录微信的话,这个session就会被微信服务器关闭的。