用python開發微信公衆平臺聊天機器人

最近用python寫了一個聊天機器人的微信公衆號,網上找的開發文檔參差不齊,官方文檔也比較老舊,還有部分小問題。於是,分享一下我的思路。

開發環境

windows sever 2008+python3.6+Flask

1.搭建服務器

服務器:隨便租個什麼服務器,或者用弄個能連外網的機子。
1.1 在服務器上裝python3.6開發環境,個人喜歡裝Anaconda,省得手動裝各種包。
1.2 新建一個test.py文件,寫如下代碼:

from flask import Flask, request, make_response

app = Flask(__name__)
app.debug = True

@app.route('/')  # 默認網址
def index():
    return '測試頁面'
    
if __name__ == '__main__':
    app.run(host="0.0.0.0", port=80,debug=True)

打開終端,cd到目錄下,運行這個文件
python test.py
彈出 * Running on http://0.0.0.0:80/ (Press CTRL+C to quit) 說明成功了。
注意這裏的端口必須是80或443,微信公衆號只支持這兩個端口。
1.3 在另一臺機子上打開瀏覽器,輸入服務器的ip地址,彈出‘測試頁面’表示服務器搭建成功。
注意:使用阿里雲服務器顯示連接超時,需要配置安全組規則,具體方法百度。

2. 配置微信公衆號

2.1 首先當然是申請一個公衆號啦。注意:素材和菜單的開發權限必須要通過認證,未認證只能發發文字,具體可以去接口權限查看。
2.2 開發者基本配置
在這裏插入圖片描述
點擊修改配置後,選擇提交肯定是驗證token失敗,因爲還需要完成代碼邏輯。
2.3 改動 test.py 爲app.py.代碼如下:

# -*- coding: utf-8 -*-
#  filename: app.py
from flask import Flask, request, make_response
import hashlib
import handle

app = Flask(__name__)
app.debug = True


@app.route('/')  # 默認網址
def index():
    return '測試頁面'


@app.route('/wx', methods=['GET', 'POST'])
def wechat_auth():  # 處理微信請求的處理函數,get方法用於認證,post方法取得微信轉發的數據
    if request.method == 'GET':
        token = 'xxxx'  # 這裏填公衆號裏設置的token。
        data = request.args
        signature = data.get('signature', '')
        timestamp = data.get('timestamp', '')
        # print(timestamp)
        nonce = data.get('nonce', '')
        echostr = data.get('echostr', '')
        s = [timestamp, nonce, token]
        s.sort()
        s = ''.join(s)
        s = s.encode(encoding='utf-8')
        #加密後的token匹配上signature
        if (hashlib.sha1(s).hexdigest() == signature):
            return make_response(echostr)
        else:
            return 'signature is error'
    else:
        rec = request.stream.read() # 接收消息
        #print(rec)
        dispatcher = handle.MsgHandler(rec)
        data = dispatcher.dispatch()
        with open("./debug.log", "a") as file:
            file.write(data)
        response = make_response(data)
        response.content_type = 'application/xml'
        return response #發送消息


if __name__ == '__main__':
    app.run(host="0.0.0.0", port=80,debug=True)

這裏只用到wechat_auth函數中的get請求部分,else後面是post請求,暫且不管,import handle是用來處理消息的,先不要看。
運行app.py python app.py
2.4 運行app.py成功後,點擊公衆號界面的提交按鈕,token驗證成功即可。

3. 消息回覆流程

核心文件

app.py : 服務器主程序,接收和發送消息。
handle.py:消息處理樞紐。
receive.py:解析接收到的消息
reply.py:回覆消息。(可以添加複雜的回覆功能)

流程圖

在這裏插入圖片描述
有了以上幾個模塊就可以完成簡單的文字消息回覆了。
在這裏插入圖片描述

4.access_token

官方文檔:access_token是公衆號的全局唯一接口調用憑據,公衆號調用各接口時都需使用access_token。開發者需要進行妥善保存。access_token的存儲至少要保留512個字符空間。access_token的有效期目前爲2個小時,需定時刷新,重複獲取將導致上次獲取的access_token失效。
可以看到獲取一次access_token的有效期是2小時,那麼我們需要寫一個定時程序,每2小時獲取一次access_token。保存在文本文檔access_token.txt中。
basic.py :定時獲取access_token

# -*- coding: utf-8 -*-
#  filename: basic.py
import urllib.request
import time
import json


class Basic():

    def __init__(self):
        self.__accessToken = ''

    def __real_get_access_token(self):
        appId = "xxxxx"  #配置公衆號時得到的id和secret
        appSecret = "xxxxxx"
        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())
        self.__accessToken = urlResp['access_token']

    def get_access_token(self):
        self.__real_get_access_token()
        return self.__accessToken


if __name__ == "__main__":

    basic = Basic()
    while True:
        token = basic.get_access_token()
        with open('access_token.txt','w',encoding='utf-8') as f:
            f.write(token)
        time.sleep(7000)

運行basic.py就能持續不斷的獲取access_token啦

5.臨時素材添加與獲取

如果你向公衆號發送語音、圖片、視頻等非文本消息,app.py只會接收到一個這樣的消息(消息模板),例如圖片消息:

<xml>
             <ToUserName><![CDATA[接收人]]></ToUserName>
             <FromUserName><![CDATA[發送人]]></FromUserName>
             <CreateTime>創建時間</CreateTime>
             <MsgType><![CDATA[image]]></MsgType>
             <Image>
             <MediaId><![CDATA[素材id]]></MediaId>
             </Image>
 </xml>

這樣一段消息經過receive.py的解析後,我們拿到“素材id”,然後向微信服務器發送一個請求,才能下載到對應的素材。
同理,如果公衆號向用戶發送圖片也需要先上傳圖片到微信服務器,服務器會返回一個“素材id”,然後公衆號再發送一段類似上面的消息,填入返回的”素材id“就可以了,其它類型消息模板可以去微信公衆號官方文檔查看。
media.py:素材上傳和下載。

# -*- coding: utf-8 -*-
# filename: media.py
import requests
import json
import os


class Media(object):
    def __init__(self):
        with open('access_token.txt','r',encoding='utf-8') as f:   #獲取access_token
            token = f.read()
        self.accessToken = token

    def get(self, mediaId, filepath):

        postUrl = "https://api.weixin.qq.com/cgi-bin/media/get?access_token=%s&media_id=%s" % (self.accessToken, mediaId)
        urlResp = requests.post(postUrl)

        headers = urlResp.headers
        if ('Content-Type: application/json\r\n' in headers) or ('Content-Type: text/plain\r\n' in headers):
            jsonDict = json.loads(urlResp.content)
            print(jsonDict)
        else:
            # print(urlResp.content.decode('ascii'))
            buffer = urlResp.content   #素材的二進制
            with open(filepath, "wb") as f:
                f.write(buffer)
            print("get successful")

    def _add(self, type,filepath):
        with open(filepath,"rb") as f:
            img = f.read()
        filename = os.path.split(filepath)[-1]
        url = 'https://api.weixin.qq.com/cgi-bin/media/upload?access_token={}&type={}'
        files = {'media':(filename,img)}
        res = requests.post(url.format(self.accessToken,type),files=files)
        return json.loads(res.content.decode('utf-8'))['media_id']

在這裏插入圖片描述

5.自定義菜單

自定義菜單能夠幫助公衆號豐富界面,讓用戶更好更快地理解公衆號的功能。開啓自定義菜單後,公衆號界面如圖所示:
在這裏插入圖片描述
menu.py :自定義菜單增刪改查

# -*- coding: utf-8 -*-
# filename: menu.py
import urllib.request


class Menu(object):
    def __init__(self):
        pass

    def create(self, postData, accessToken):
        postUrl = "https://api.weixin.qq.com/cgi-bin/menu/create?access_token=%s" % accessToken
        # if isinstance(postData, 'unicode'):
        postData = postData.encode('utf-8')
        urlResp = urllib.request.urlopen(url=postUrl, data=postData)
        print(urlResp.read())

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


if __name__ == '__main__':
    myMenu = Menu()
    postJson = """
    {
        "button":
        [
            {
                "name": "小迪互動",
                "sub_button":
				[
                    {
                        "type": "click",
                        "name": "圖片文字識別",
                        "key": "ocr"
                    },
                    {
                        "type": "click",
                        "name": "語音聊天",
                        "key": "speech"
                    },
                    {
                        "type": "click",
                        "name": "文字聊天",
                        "key": "text"
                    }
                ]
            },
            {
                "name": "精選欄目",
                "sub_button":
                [
                    {
                        "type": "view",
                        "name": "往期文章",
                        "url": "https://mp.weixin.qq.com/mp/profile_ext?action=home&__biz=MzU4ODcxOTE2OA==&scene=124#wechat_redirect"
                    }
                ]
            },
            {
                "name": "關於我們",
                "sub_button":
				[
                    {
                        "type": "click",
                        "name": "DepthsData",
                        "key": "depth"
                    },
                    {
                        "type": "click",
                        "name": "RPA",
                        "key": "rpa"
                    }
                ]
            }
          ]
    }
    """
    with open('access_token.txt','r',encoding='utf-8') as f:
            accessToken = f.read()
    myMenu.create(postJson, accessToken)

運行menu.py一次性創建自定義菜單,有些許延遲。
創建成功後,點擊菜單會向公衆號發送一個“事件消息”,根據你自定義的click-key,判斷該事件屬於哪個菜單。
此外,新用戶的訂閱會發送一個“subscribe”的“事件消息”,可以利用它給新用戶打招呼噢。

6. 總結

到此,一個功能還算齊全的公衆號開發完畢。
1.補充完整的流程圖:
在這裏插入圖片描述
圖中紅色字體的文件是需要運行的。
2.文件結構:
在這裏插入圖片描述
3.剩餘代碼
handle.py

# -*- coding: utf-8 -*-
# filename: handle.py
from receive import parse_xml
from reply import event_reply,text_reply,ocr_reply
from media import Media
from helpfunc import speech2text,text2speech


class MsgHandler(object):
    """
    針對type不同,轉交給不同的處理函數。直接處理即可
    """

    def __init__(self, msg):
        self.msg = parse_xml(msg)
        self.time = int(time.time())
        self.temp_text = """
        <xml>
             <ToUserName><![CDATA[{}]]></ToUserName>
             <FromUserName><![CDATA[{}]]></FromUserName>
             <CreateTime>{}</CreateTime>
             <MsgType><![CDATA[text]]></MsgType>
             <Content><![CDATA[{}]]></Content>
         </xml>
        """
        self.temp_image = """
        <xml>
             <ToUserName><![CDATA[{}]]></ToUserName>
             <FromUserName><![CDATA[{}]]></FromUserName>
             <CreateTime>{}</CreateTime>
             <MsgType><![CDATA[image]]></MsgType>
             <Image>
             <MediaId><![CDATA[{}]]></MediaId>
             </Image>
         </xml>
        """
        self.temp_voice = """
        <xml>
            <ToUserName><![CDATA[{}]]></ToUserName>
            <FromUserName><![CDATA[{}]]></FromUserName>
            <CreateTime>{}</CreateTime>
            <MsgType><![CDATA[voice]]></MsgType>
            <Voice>
            <MediaId><![CDATA[{}]]></MediaId></Voice>
        </xml>
        """

    def dispatch(self):
        self.result = ""  # 統一的公衆號出口數據
        if self.msg.MsgType == "text":
            self.result = self.textHandle()
        elif self.msg.MsgType== "voice":
            self.result = self.voiceHandle()
        elif self.msg.MsgType == 'image':
            self.result = self.imageHandle()
        elif self.msg.MsgType == 'video':
            self.result = self.videoHandle()
        elif self.msg.MsgType == 'shortvideo':
            self.result = self.shortVideoHandle()
        elif self.msg.MsgType == 'location':
            self.result = self.locationHandle()
        elif self.msg.MsgType == 'link':
            self.result = self.linkHandle()
        elif self.msg.MsgType == 'event':
            self.result = self.eventHandle()
        return self.result

    def textHandle(self):

        result = text_reply(self.msg.Content,self.msg.FromUserName)
        response = self.temp_text.format(self.msg.FromUserName, self.msg.ToUserName, self.time, result)
      
        return response

    def voiceHandle(self):
        # template = self.temp_voice
        mediaid = self.msg.MediaId
        filepath = r'voice/' + str(time.time()) + '.mp3'
        Media().get(mediaid, filepath)#下載素材
        text = speech2text(filepath)
        result = text_reply(text,self.msg.FromUserName)
        voice_path = text2speech(result)
        r_mediaid = Media()._add('voice', voice_path)#上傳素材
        response = self.temp_voice.format(self.msg.FromUserName, self.msg.ToUserName, self.time, r_mediaid)
        return response

    def imageHandle(self):

        mediaid = self.msg.MediaId
        style = get_state(self.msg.FromUserName)
        if style == 'img-ocr':
            filepath = r'pic/' + str(time.time()) + '.jpg'
            Media().get(mediaid, filepath)
            try:
                result = ocr_reply(imgpath=filepath)
            except:
                result = '圖上好像沒有字吧'
            respnse = self.temp_text.format(self.msg.FromUserName, self.msg.ToUserName, self.time, result)
        else:
            respnse = self.temp_image.format(self.msg.FromUserName, self.msg.ToUserName, self.time, mediaid)
        return respnse

    def videoHandle(self):
        return self.temp_text.format(self.msg.FromUserName, self.msg.ToUserName, self.time, '不支持的消息類型!')

    def shortVideoHandle(self):
        return self.temp_text.format(self.msg.FromUserName, self.msg.ToUserName, self.time, '不支持的消息類型!')

    def locationHandle(self):
        return self.temp_text.format(self.msg.FromUserName, self.msg.ToUserName, self.time, '不支持的消息類型!')

    def linkHandle(self):
        return self.temp_text.format(self.msg.FromUserName, self.msg.ToUserName, self.time, '不支持的消息類型!')


    def eventHandle(self):

        result = event_reply(self.msg,self.msg.FromUserName)
        response = self.temp_text.format(self.msg.FromUserName, self.msg.ToUserName, self.time, result)

        return response

receive.py

# -*- coding: utf-8 -*-
# filename: receive.py
import xml.etree.ElementTree as ET


def parse_xml(web_data):
    if len(web_data) == 0:
        return None
    xmlData = ET.fromstring(web_data)
    msg_type = xmlData.find('MsgType').text
    print(msg_type)
    if msg_type == 'event':
        event_type = xmlData.find('Event').text
        if event_type == 'CLICK':
            return Click(xmlData)
        elif event_type in ('subscribe', 'unsubscribe'):
            return Subscribe(xmlData)
        #elif event_type == 'VIEW':
            #return View(xmlData)
        #elif event_type == 'LOCATION':
            #return LocationEvent(xmlData)
        #elif event_type == 'SCAN':
            #return Scan(xmlData)
    elif msg_type == 'text':
        return TextMsg(xmlData)
    elif msg_type == 'image':
        return ImageMsg(xmlData)
    elif msg_type == 'voice':
        return VoiceMsg(xmlData)
    else:
        return Msg(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


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


class Subscribe(Msg):
    def __init__(self, xmlData):
        Msg.__init__(self, xmlData)
        self.Event = xmlData.find('Event').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 VoiceMsg(Msg):
    def __init__(self, xmlData):
        Msg.__init__(self, xmlData)
        self.MediaId = xmlData.find("MediaId").text

reply.py

放心吧,這個我是不會放出來的,畢竟我是一個“稱職”的員工。

好了,以上就是用python開發微信公衆平臺的全部了,覺得好看點個贊再走吧!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章