公众号自定义菜单开发

写在前面

因为前边给公众号添加智能对话机器人,启用了公众号后台服务器配置。然后原来的公众号的后台自定义菜单就失效了,所以没办法,我们也只能去自己开发了,也就有了这篇文章。

这篇文章会用到给你的公众号添加一个智能机器人的一些代码,所以没看过之前文章的同学可以先去看一下。

虽然自定义菜单的流程和代码都完成了,但是自定义菜单需要认证的公众号才行,目前个人的公众号认证功能正在逐步开放中,应该不久就都可以了,如果你和我一样还没有收到个人认证的通知,那么就耐心等待一段时间吧。

获取 access_token

因为在自定义菜单的开发中我们需要用到 access_token,所以我们需要首先获取到 access_token,后边很多其他的业务也需要用到 access_token。

这是公众号文档里对 access_token 的说明,我们先看一下。

access_token 是公众号的全局唯一接口调用凭据,公众号调用各接口时都需使用access_token。开发者需要进行妥善保存。access_token 的存储至少要保留 512 个字符空间。access_token 的有效期目前为 2 个小时,需定时刷新,重复获取将导致上次获取的access_token 失效。

公众平台的API调用所需的access_token的使用及生成方式我们需要遵循以下几个条件和说明:

  • 因为各个接口调用都需要 access_token,我们最好使用中控服务器单独获取和刷新,避免各自刷新生成,造成 access_token 覆盖冲突而影响业务;
  • 在 access_token 中有一个参数 expire_in 来表示 access_token 的有效期,现在是 7200 秒。我们自己可以根据这个时间去提前刷新 access_token,在刷新过程中,老的 access_token,可以继续使用,公众平台后台会保证在5分钟内,新老 access_token 都可用,这保证了第三方业务的平滑过渡;
  • access_token的有效时间可能会在未来有调整,所以我们不仅需要内部定时主动刷新,还需要提供被动刷新access_token的接口,这样在调用获知access_token已超时的情况下,可以触发access_token的刷新流程;

接口调用请求说明

https请求方式: GET https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET

  • grant_type:获取 access_token 填写 client_credential
  • appid:第三方用户唯一凭证,可在公众号后台获得
  • secret:第三方用户唯一凭证密钥,即appsecret,可在公众号后台获得

返回参数说明

请求成功的话,我们会获得下面 Json 数据:

{"access_token":"ACCESS_TOKEN","expires_in":7200}
  • access_token:获取到的凭证
  • expires_in:凭证有效时间,单位:秒

代码实现

我们创建一个类来获取和刷新 access_token,basic.py

import urllib
import time
import json


class Basic:
    def __init__(self):
        self.__accessToken = ''
        self.__leftTime = 0

    def __real_get_access_token(self):
        appId = "你的appId"
        appSecret = "你的appSecret"
        postUrl = ("https://api.weixin.qq.com/cgi-bin/token?grant_type="
                   "client_credential&appid=%s&secret=%s" % (appId, appSecret))
        urlResp = urllib.request.urlopen(postUrl)
        urlResp = json.loads(urlResp.read())
        print(urlResp)
        self.__accessToken = urlResp['access_token']
        self.__leftTime = urlResp['expires_in']
        print(self.__accessToken)

    # 外部获取 access_token 接口,同样 leftTime 如果小于十秒我们就刷新 access_token
    def get_access_token(self):
        if self.__leftTime < 10:
            self.__real_get_access_token()
        return self.__accessToken

    # 刷新 leftTime,如果小于十秒我们就刷新 access_token
    def run(self):
        while(True):
            if self.__leftTime > 10:
                time.sleep(2)
                self.__leftTime -= 2
            else:
                self.__real_get_access_token()

然后我们单独运行一个获取刷新 access_token 的程序。

accessToken.py

from basic import Basic

basic = Basic()


def getAccessToken():
    return basic.get_access_token()


if __name__ == "__main__":
    basic.run()

后面其他的业务需要 access_token,都通过这个 accessToken 的 getAccessToken 方法来获取。后台会自动刷新。

自定义菜单

我们需要的 access_token 已经拿到了,那么我们就可以正式开始菜单的开发了。

自定义菜单要求:

  • 自定义菜单最多包括3个一级菜单,每个一级菜单最多包含5个二级菜单。
  • 一级菜单最多4个汉字,二级菜单最多7个汉字,多出来的部分将会以“...”代替。
  • 创建自定义菜单后,菜单的刷新策略是,在用户进入公众号会话页或公众号profile页时,如果发现上一次拉取菜单的请求在5分钟以前,就会拉取一下菜单,如果菜单有更新,就会刷新客户端的菜单。测试时可以尝试取消关注公众账号后再次关注,则可以看到创建后的效果。

自定义菜单按钮类型:

  • click:点击推事件,用户点击click类型按钮后,微信服务器会通过消息接口推送消息类型为event的结构给开发者;
  • view:跳转URL,用户点击view类型按钮后,微信客户端将会打开开发者在按钮中填写的网页URL。
  • scancode_push:扫码推事件,用户点击按钮后,微信客户端将调起扫一扫工具,完成扫码操作后显示扫描结果(如果是URL,将进入URL),且会将扫码的结果传给开发者。
  • scancode_waitmsg:扫码推事件且弹出“消息接收中”提示框用户点击按钮后,微信客户端将调起扫一扫工具,完成扫码操作后,将扫码的结果传给开发者,同时收起扫一扫工具,然后弹出“消息接收中”提示框,随后可能会收到开发者下发的消息。
  • pic_sysphoto:弹出系统拍照发图用户点击按钮后,微信客户端将调起系统相机,完成拍照操作后,会将拍摄的相片发送给开发者,并推送事件给开发者,同时收起系统相机,随后可能会收到开发者下发的消息。
  • pic_photo_or_album:弹出拍照或者相册发图用户点击按钮后,微信客户端将弹出选择器供用户选择“拍照”或者“从手机相册选择”。用户选择后即走其他两种流程。
  • pic_weixin:弹出微信相册发图器用户点击按钮后,微信客户端将调起微信相册,完成选择操作后,将选择的相片发送给开发者的服务器,并推送事件给开发者,同时收起相册,随后可能会收到开发者下发的消息。
  • location_select:弹出地理位置选择器用户点击按钮后,微信客户端将调起地理位置选择工具,完成选择操作后,将选择的地理位置发送给开发者的服务器,同时收起位置选择工具,随后可能会收到开发者下发的消息。
  • media_id:下发消息(除文本消息)用户点击media_id类型按钮后,微信服务器会将开发者填写的永久素材id对应的素材下发给用户,永久素材类型可以是图片、音频、视频、图文消息。
  • view_limited:跳转图文消息URL用户点击view_limited类型按钮后,微信客户端将打开开发者在按钮中填写的永久素材id对应的图文消息URL,永久素材类型只支持图文消息。

接口调用请求说明

http请求方式:POST(请使用https协议) https://api.weixin.qq.com/cgi-bin/menu/create?access_token=ACCESS_TOKEN

  • access_token:即我们上面获取的 access_token

代码实现

下面我们通过代码来看一实现一个 click、view、media_id 三种类型的按钮。

menu.py

import urllib
import accessToken

class Menu(object):
    postJson = """
    {
        "button":
        [
            {
                "type": "click",
                "name": "开发指引",
                "key":  "mpGuide"
            },
            {
                "name": "公众平台",
                "sub_button":
                [
                    {
                        "type": "view",
                        "name": "更新公告",
                        "url": "http://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1418702138&token=&lang=zh_CN"
                    },
                    {
                        "type": "view",
                        "name": "接口权限说明",
                        "url": "http://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1418702138&token=&lang=zh_CN"
                    },
                    {
                        "type": "view",
                        "name": "返回码说明",
                        "url": "http://mp.weixin.qq.com/wiki?t=resource/res_main&id=mp1433747234&token=&lang=zh_CN"
                    }
                ]
            },
            {
                "type": "media_id",
                "name": "旅行",
                "media_id": "z2zOokJvlzCXXNhSjF46gdx6rSghwX2xOD5GUV9nbX4"
            }
          ]
    }
    """.encode('utf-8')

    def __init__(self):
        pass
    def create(self, accessToken):
        postUrl = "https://api.weixin.qq.com/cgi-bin/menu/create?access_token=%s" % accessToken
        urlResp = urllib.urlopen(url=postUrl, data=self.postData)
        print urlResp.read()

    def query(self, accessToken):
        postUrl = "https://api.weixin.qq.com/cgi-bin/menu/get?access_token=%s" % accessToken
        urlResp = urllib.urlopen(url=postUrl)
        print urlResp.read()

    def delete(self, accessToken):
        postUrl = "https://api.weixin.qq.com/cgi-bin/menu/delete?access_token=%s" % accessToken
        urlResp = urllib.urlopen(url=postUrl)
        print urlResp.read()
        
    #获取自定义菜单配置接口
    def get_current_selfmenu_info(self, accessToken):
        postUrl = "https://api.weixin.qq.com/cgi-bin/get_current_selfmenu_info?access_token=%s" % accessToken
        urlResp = urllib.urlopen(url=postUrl)
        print urlResp.read()

现在自定义的菜单生成了,我们通过 click 类型的 button 为例,来处理当点击菜单时收到的消息。微信后台会推送一个 event 类型的 xml 给我们。

<xml>
    <ToUserName><![CDATA[toUser]]></ToUserName>
    <FromUserName><![CDATA[FromUser]]></FromUserName>
    <CreateTime>123456789</CreateTime>
    <MsgType><![CDATA[event]]></MsgType>
    <Event><![CDATA[CLICK]]></Event>
    <EventKey><![CDATA[EVENTKEY]]></EventKey>
</xml>
  • ToUserName:开发者微信号
  • FromUserName:发送方帐号(一个OpenID)
  • CreateTime:消息创建时间 (整型)
  • MsgType:消息类型,event
  • Event:事件类型,CLICK
  • EventKey:事件KEY值,与自定义菜单接口中KEY值对应

整个消息的流程图:

流程图

我们根据消息格式和流程来写代码。
修改 main.py

from flask import Flask
from flask import request
import hashlib
import re
import tuling
import receive
import reply
from menu import Menu
import accessToken


app = Flask(__name__)


@app.route("/")
def index():
    return "Hello World!"

# 公众号后台消息路由入口
@app.route("/wechat", methods=["GET", "POST"])
def wechat():
    # 验证使用的GET方法
    if request.method == "GET":
        signature = request.args.get('signature')
        timestamp = request.args.get('timestamp')
        nonce = request.args.get('nonce')
        echostr = request.args.get('echostr')
        token = "公众号后台填写的token"

        # 进行排序
        dataList = [token, timestamp, nonce]
        dataList.sort()
        result = "".join(dataList)

        #哈希加密算法得到hashcode
        sha1 = hashlib.sha1()
        sha1.update(result.encode("utf-8"))
        hashcode = sha1.hexdigest()

        if hashcode == signature:
            return echostr
        else:
            return ""
    else:
        recMsg = receive.parse_xml(request.data)
        if isinstance(recMsg, receive.Msg):
            toUser = recMsg.FromUserName
            fromUser = recMsg.ToUserName
            if recMsg.MsgType == 'text':
                content = recMsg.Content
                # userId 长度小于等于32位
                if len(toUser) > 31:
                    userid = str(toUser[0:30])
                else:
                    userid = str(toUser)
                userid = re.sub(r'[^A-Za-z0-9]+', '', userid)
                tulingReplay = tuling.tulingReply(content, userid)
                replyMsg = reply.TextMsg(toUser, fromUser, tulingReplay)
                return replyMsg.send()
            elif recMsg.MsgType == 'image':
                mediaId = recMsg.MediaId
                replyMsg = reply.ImageMsg(toUser, fromUser, mediaId)
                return replyMsg.send()
        if isinstance(recMsg, receive.EventMsg):
            if recMsg.Event == 'subscribe':
                subscribe_reply = "终于等到你了。~\n" \
                                      "在这里,我们可以一起学习知识,\n" \
                                      "一起努力成长。\n" \
                                      "你烦闷时,我还可以陪你聊天解闷哦~"
                replyMsg = reply.TextMsg(toUser, fromUser, subscribe_reply)
                return replyMsg.send()
            elif recMsg.Event == 'CLICK':
                if recMsg.Eventkey == 'mpGuide':
                    content = u"编写中,尚未完成".encode('utf-8')
                    replyMsg = reply.TextMsg(toUser, fromUser, content)
                    return replyMsg.send()
            elif recMsg.Event == 'VIEW':
                pass

        return reply.Msg().send()

if __name__ == "__main__":
    menu = Menu()
    access_token = accessToken.getAccessToken()
    menu.create(access_token)
    app.run(host='0.0.0.0', port=80)    #公众号后台只开放了80端口

修改 receive.py:

import xml.etree.ElementTree as ET

def parse_xml(receiveData):
    if len(receiveData) == 0:
        return None
    xmlData = ET.fromstring(receiveData)
    msgType = xmlData.find('MsgType').text
    if msgType == 'text':
        return TextMsg(xmlData)
    elif msgType == 'image':
        return ImageMsg(xmlData)
    elif msgType == 'event':
        event_type = xmlData.find('Event').text
        if event_type in ('subscribe', 'unsubscribe'):
            return Subscribe(xmlData)
        elif event_type == 'CLICK':
            return Click(xmlData)
        elif event_type == 'VIEW':
            return View(xmlData)

class Msg(object):
    def __init__(self, xmlData):
        self.ToUserName = xmlData.find('ToUserName').text
        self.FromUserName = xmlData.find('FromUserName').text
        self.CreateTime = xmlData.find('CreateTime').text
        self.MsgType = xmlData.find('MsgType').text
        self.MsgId = xmlData.find('MsgId').text

class TextMsg(Msg):
    def __init__(self, xmlData):
        Msg.__init__(self, xmlData)
        self.Content = xmlData.find('Content').text

class ImageMsg(Msg):
    def __init__(self, xmlData):
        Msg.__init__(self, xmlData)
        self.PicUrl = xmlData.find('PicUrl').text
        self.MediaId = xmlData.find('MediaId').text

class EventMsg(object):
    def __init__(self, xmlData):
        self.ToUserName = xmlData.find('ToUserName').text
        self.FromUserName = xmlData.find('FromUserName').text
        self.CreateTime = xmlData.find('CreateTime').text
        self.MsgType = xmlData.find('MsgType').text
        self.Event = xmlData.find('Event').text

class Subscribe(EventMsg):
    def __init__(self, xmlData):
        EventMsg.__init__(self, xmlData)

class Click(EventMsg):
    def __init__(self, xmlData):
        EventMsg.__init__(self, xmlData)
        self.EventKey = xmlData.find('EventKey').text

class View(EventMsg):
    def __init__(self, xmlData):
        EventMsg.__init__(self, xmlData)
        self.EventKey = xmlData.find('EventKey').text
        self.MenuId = xmlData.find('MenuId').text

然后我们重启后台服务器,就可以测试我们的自定义菜单了,我们上边只对 click 的事件进行了处理,view 类型、media_id 类型的本身就更容易实现,我们这里就不详细展开这两种类型了,其中 media_id 类型的需要一个 media_id 的参数,也就是你公众号后台的素材的 id,我们可以参考微信公众号开发文档中的素材获取来获得。

好了,我们的自定义菜单到这就完成了,我们可以根据我们自己公众号的不同需求来定义自己的菜单了。