Serverless與人工智能實現微信公衆號的智能服務

如何才能給微信公衆號增加更多功能?傳統的做法是使用一臺服務器搭建微信公衆號的後臺服務,那麼我們能否利用Serverless架構,通過超簡單的方法來實現簡單的微信公衆號後臺?

初步搭建

Serverless原生開發

首先需要提前準備一個微信公衆號,然後爲函數計算服務申請固定IP:

點擊白名單之後就可以填寫表單,完成固定公網出口IP的申請。

接下來就是代碼開發。

  1. 依據參考文檔,將函數綁定到公衆號後臺:

我們可以先在函數中按照文檔完成一個基本的鑑定功能:

def checkSignature(param):
    '''
    文檔地址:https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Access_Overview.html
    :param param:
    :return:
    '''
    signature = param['signature']
    timestamp = param['timestamp']
    nonce = param["nonce"]
    tmparr = [wxtoken, timestamp, nonce]
    tmparr.sort()
    tmpstr = ''.join(tmparr)
    tmpstr = hashlib.sha1(tmpstr.encode("utf-8")).hexdigest()
    return tmpstr == signature

再定義一個基本的回覆方法:

def response(body, status=200):
    return {
        "isBase64Encoded": False,
        "statusCode": status,
        "headers": {"Content-Type": "text/html"},
        "body": body
    }

函數入口處:

def main_handler(event, context):    
    if 'echostr' in event['queryString']:  # 接入時的校驗
        return response(event['queryString']['echostr'] if checkSignature(event['queryString']) else False)

配置Yaml:

# serverless.yml
Weixin_GoServerless:
  component: "@serverless/tencent-scf"
  inputs:
    name: Weixin_GoServerless
    codeUri: ./Admin
    handler: index.main_handler
    runtime: Python3.6
    region: ap-shanghai
    description: 微信公衆號後臺服務器配置
    memorySize: 128
    timeout: 20
    environment:
      variables:
        wxtoken: 自定義一個字符串
        appid: 暫時不寫
        secret: 暫時不寫
    events:
      - apigw:
          name: Weixin_GoServerless
          parameters:
            protocols:
              - https
            environment: release
            endpoints:
              - path: /
                method: ANY
                function:
                  isIntegratedResponse: TRUE

執行代碼,完成部署:

在衆號後臺選擇基本配置:

選擇修改配置:

需要注意的是:

  • URL,寫部署完成返回的地址,並且在最後加一個/

  • Token,寫Yaml中的wxtoken,兩個地方要保持一樣的字符串

  • EncodingAESKey,點擊隨機生成

  • 消息加密方法可以選擇明文

完成之後,點擊提交:

看到提交成功,就說明已經完成了第一步驟的綁定,接下來,我們到函數的後臺:

打開這個固定出口IP,複製IP地址:

點擊查看->修改,並將IP地址複製粘貼進來,保存。
同時查看開發者ID和密碼:

並將這兩個內容複製粘貼,放到環境變量中:

至此,我們就完成了一個公衆號後臺服務的綁定。爲了方便之後的操作,先獲取一下全局變量:

wxtoken = os.environ.get('wxtoken')
appid = os.environ.get('appid')
secret = os.environ.get('secret')
  1. 接下來對各個模塊進行編輯(本文只提供部分簡單基礎的模塊,更多功能實現可以參考微信公衆號文檔實現)
  • 獲取AccessToken模塊:
def getAccessToken():
    '''
    文檔地址:https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Get_access_token.html
    正常返回:{"access_token":"ACCESS_TOKEN","expires_in":7200}
    異常返回:{"errcode":40013,"errmsg":"invalid appid"}
    :return:
    '''
    url = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s" % (appid, secret)
    accessToken = json.loads(urllib.request.urlopen(url).read().decode("utf-8"))
    print(accessToken)
    return None if "errcode" in accessToken else accessToken["access_token"]
  • 創建自定義菜單模塊:
def setMenu(menu):
    '''
    文檔地址:https://developers.weixin.qq.com/doc/offiaccount/Custom_Menus/Creating_Custom-Defined_Menu.html
    正確返回:{"errcode":0,"errmsg":"ok"}
    異常返回:{"errcode":40018,"errmsg":"invalid button name size"}
    :return:
    '''
    accessToken = getAccessToken()
    if not accessToken:
        return "Get Access Token Error"

    url = "https://api.weixin.qq.com/cgi-bin/menu/create?access_token=%s" % accessToken
    postData = urllib.parse.urlencode(menu).encode("utf-8")
    requestAttr = urllib.request.Request(url=url, data=postData)
    responseAttr = urllib.request.urlopen(requestAttr)
    responseData = json.loads(responseAttr.read())
    return responseData['errmsg'] if "errcode" in responseData else "success"
  • 常見消息回覆模塊:
def textXML(body, event):
    '''
    :param body: {"msg": "test"}
        msg: 必填,回覆的消息內容(換行:在content中能夠換行,微信客戶端就支持換行顯示)
    :param event:
    :return:
    '''
    return """<xml><ToUserName><![CDATA[{toUser}]]></ToUserName>
              <FromUserName><![CDATA[{fromUser}]]></FromUserName>
              <CreateTime>{time}</CreateTime>
              <MsgType><![CDATA[text]]></MsgType>
              <Content><![CDATA[{msg}]]></Content></xml>""".format(toUser=event["FromUserName"],
                                                                   fromUser=event["ToUserName"],
                                                                   time=int(time.time()),
                                                                   msg=body["msg"])


def pictureXML(body, event):
    '''
    :param body:  {"media_id": 123}
        media_id: 必填,通過素材管理中的接口上傳多媒體文件,得到的id。
    :param event:
    :return:
    '''
    return """<xml><ToUserName><![CDATA[{toUser}]]></ToUserName>
              <FromUserName><![CDATA[{fromUser}]]]></FromUserName>
              <CreateTime>{time}</CreateTime>
              <MsgType><![CDATA[image]]></MsgType>
              <Image>
                <MediaId><![CDATA[{media_id}]]></MediaId>
              </Image></xml>""".format(toUser=event["FromUserName"],
                                       fromUser=event["ToUserName"],
                                       time=int(time.time()),
                                       media_id=body["media_id"])


def voiceXML(body, event):
    '''
    :param body: {"media_id": 123}
        media_id: 必填,通過素材管理中的接口上傳多媒體文件,得到的id
    :param event:
    :return:
    '''
    return """<xml><ToUserName><![CDATA[{toUser}]]></ToUserName>
              <FromUserName><![CDATA[{fromUser}]]></FromUserName>
              <CreateTime>{time}</CreateTime>
              <MsgType><![CDATA[voice]]></MsgType>
              <Voice>
                <MediaId><![CDATA[{media_id}]]></MediaId>
              </Voice></xml>""".format(toUser=event["FromUserName"],
                                       fromUser=event["ToUserName"],
                                       time=int(time.time()),
                                       media_id=body["media_id"])


def videoXML(body, event):
    '''
    :param body: {"media_id": 123, "title": "test", "description": "test}
        media_id: 必填,通過素材管理中的接口上傳多媒體文件,得到的id
        title::選填,視頻消息的標題
        description:選填,視頻消息的描述
    :param event:
    :return:
    '''
    return """<xml><ToUserName><![CDATA[{toUser}]]></ToUserName>
              <FromUserName><![CDATA[{fromUser}]]></FromUserName>
              <CreateTime>{time}</CreateTime>
              <MsgType><![CDATA[video]]></MsgType>
              <Video>
                <MediaId><![CDATA[{media_id}]]></MediaId>
                <Title><![CDATA[{title}]]></Title>
                <Description><![CDATA[{description}]]></Description>
              </Video></xml>""".format(toUser=event["FromUserName"],
                                       fromUser=event["ToUserName"],
                                       time=int(time.time()),
                                       media_id=body["media_id"],
                                       title=body.get('title', ''),
                                       description=body.get('description', ''))


def musicXML(body, event):
    '''
    :param body:  {"media_id": 123, "title": "test", "description": "test}
        media_id:必填,縮略圖的媒體id,通過素材管理中的接口上傳多媒體文件,得到的id
        title:選填,音樂標題
        description:選填,音樂描述
        url:選填,音樂鏈接
        hq_url:選填,高質量音樂鏈接,WIFI環境優先使用該鏈接播放音樂
    :param event:
    :return:
    '''
    return """<xml><ToUserName><![CDATA[{toUser}]]></ToUserName>
              <FromUserName><![CDATA[{fromUser}]]></FromUserName>
              <CreateTime>{time}</CreateTime>
              <MsgType><![CDATA[music]]></MsgType>
              <Music>
                <Title><![CDATA[{title}]]></Title>
                <Description><![CDATA[{description}]]></Description>
                <MusicUrl><![CDATA[{url}]]></MusicUrl>
                <HQMusicUrl><![CDATA[{hq_url}]]></HQMusicUrl>
                <ThumbMediaId><![CDATA[{media_id}]]></ThumbMediaId>
              </Music></xml>""".format(toUser=event["FromUserName"],
                                       fromUser=event["ToUserName"],
                                       time=int(time.time()),
                                       media_id=body["media_id"],
                                       title=body.get('title', ''),
                                       url=body.get('url', ''),
                                       hq_url=body.get('hq_url', ''),
                                       description=body.get('description', ''))


def articlesXML(body, event):
    '''
    :param body: 一個list [{"title":"test", "description": "test", "picUrl": "test", "url": "test"}]
        title:必填,圖文消息標題
        description:必填,圖文消息描述
        picUrl:必填,圖片鏈接,支持JPG、PNG格式,較好的效果爲大圖360*200,小圖200*200
        url:必填,點擊圖文消息跳轉鏈接
    :param event:
    :return:
    '''
    if len(body["articles"]) > 8:  # 最多隻允許返回8個
        body["articles"] = body["articles"][0:8]
    tempArticle = """<item>
      <Title><![CDATA[{title}]]></Title>
      <Description><![CDATA[{description}]]></Description>
      <PicUrl><![CDATA[{picurl}]]></PicUrl>
      <Url><![CDATA[{url}]]></Url>
    </item>"""
    return """<xml><ToUserName><![CDATA[{toUser}]]></ToUserName>
              <FromUserName><![CDATA[{fromUser}]]></FromUserName>
              <CreateTime>{time}</CreateTime>
              <MsgType><![CDATA[news]]></MsgType>
              <ArticleCount>{count}</ArticleCount>
              <Articles>
                {articles}
              </Articles></xml>""".format(toUser=event["FromUserName"],
                                          fromUser=event["ToUserName"],
                                          time=int(time.time()),
                                          count=len(body["articles"]),
                                          articles="".join([tempArticle.format(
                                              title=eveArticle['title'],
                                              description=eveArticle['description'],
                                              picurl=eveArticle['picurl'],
                                              url=eveArticle['url']
                                          ) for eveArticle in body["articles"]]))
  • 對main_handler進行修改,使其:

    • 識別綁定功能

    • 識別基本信息

    • 識別特殊額外請求(例如通過url觸發自定義菜單的更新)

整體代碼:

def main_handler(event, context):
    print('event: ', event)

    if event["path"] == '/setMenu':  # 設置菜單接口
        menu = {
            "button": [
                {
                    "type": "view",
                    "name": "精彩文章",
                    "url": "https://mp.weixin.qq.com/mp/homepage?__biz=Mzg2NzE4MDExNw==&hid=2&sn=168bd0620ee79cd35d0a80cddb9f2487"
                },
                {
                    "type": "view",
                    "name": "開源項目",
                    "url": "https://mp.weixin.qq.com/mp/homepage?__biz=Mzg2NzE4MDExNw==&hid=1&sn=69444401c5ed9746aeb1384fa6a9a201"
                },
                {
                    "type": "miniprogram",
                    "name": "在線編程",
                    "appid": "wx453cb539f9f963b2",
                    "pagepath": "/page/index"
                }]
        }
        return response(setMenu(menu))

    if 'echostr' in event['queryString']:  # 接入時的校驗
        return response(event['queryString']['echostr'] if checkSignature(event['queryString']) else False)
    else:  # 用戶消息/事件
        event = getEvent(event)
        if event["MsgType"] == "text":
            # 文本消息
            return response(body=textXML({"msg": "這是一個文本消息"}, event))
        elif event["MsgType"] == "image":
            # 圖片消息
            return response(body=textXML({"msg": "這是一個圖片消息"}, event))
        elif event["MsgType"] == "voice":
            # 語音消息
            pass
        elif event["MsgType"] == "video":
            # 視頻消息
            pass
        elif event["MsgType"] == "shortvideo":
            # 小視頻消息
            pass
        elif event["MsgType"] == "location":
            # 地理位置消息
            pass
        elif event["MsgType"] == "link":
            # 鏈接消息
            pass
        elif event["MsgType"] == "event":
            # 事件消息
            if event["Event"] == "subscribe":
                # 訂閱事件
                if event.get('EventKey', None):
                    # 用戶未關注時,進行關注後的事件推送(帶參數的二維碼)
                    pass
                else:
                    # 普通關注
                    pass
            elif event["Event"] == "unsubscribe":
                # 取消訂閱事件
                pass
            elif event["Event"] == "SCAN":
                # 用戶已關注時的事件推送(帶參數的二維碼)
                pass
            elif event["Event"] == "LOCATION":
                # 上報地理位置事件
                pass
            elif event["Event"] == "CLICK":
                # 點擊菜單拉取消息時的事件推送
                pass
            elif event["Event"] == "VIEW":
                # 點擊菜單跳轉鏈接時的事件推送
                pass

在上述代碼中可以看到:

if event["MsgType"] == "text":
    # 文本消息
    return response(body=textXML({"msg": "這是一個文本消息"}, event))
elif event["MsgType"] == "image":
    # 圖片消息
    return response(body=textXML({"msg": "這是一個圖片消息"}, event))

當用戶發送了文本消息時,我們給用戶回覆一個文本消息:“這是一個文本消息”,當用戶發送了一個圖片,我們給用戶返回“這是一個圖片消息”,用這兩個功能測試後臺的連通性:

可以看到,系統已經可以正常返回。

這樣一個簡單的小框架或者小Demo的意義是什麼呢?第一,可以證明我們可以很輕量的通過一個函數來實現微信公衆號的後端服務,第二這些都是基礎能力,我們可以在此基礎上,“肆無忌憚”的添加創新力,例如:

  1. 用戶傳過來的是圖片消息,我們可以通過一些識圖API告訴用戶這個圖片包括了什麼

  2. 用戶傳過來的是文字消息,我們可以先設定一些幫助信息/檢索信息進行對比,如果沒找到就給用戶開啓聊天功能(這裏涉及到人工智能中的自然語言處理,例如對話、文本相似度檢測等等)

  3. 如果用戶發送的是語音,我們還可以將其轉成文本,生成對話消息,然後再轉換成語音返回給用戶

  4. 如果用戶發送了地理位置信息,我們可以返回給用戶所在經緯度的街景信息或者周邊的信息/生活服務信息等

使用Werobot框架

上面是通過Serverless原生開發的方法進行對接,除此之外,我們還可以選擇一些已有的框架,例如werobot等。

werobot爲例:

WeRoBot 是一個微信公衆號開發框架。通過Serverless Component中的tencent-werobot組件快速部署該框架:

Weixin_Werobot:
  component: "@serverless/tencent-werobot"
  inputs:
    functionName: Weixin_Werobot
    code: ./test
    werobotProjectName: app
    werobotAttrName: robot
    functionConf:
      timeout: 10
      memorySize: 256
      environment:
        variables:
          wxtoken: 你的token
      apigatewayConf:
        protocols:
          - http
        environment: release

新建代碼:

import os
import werobot

robot = werobot.WeRoBot(token=os.environ.get('wxtoken'))

robot.config['SESSION_STORAGE'] = False
robot.config["APP_ID"] = os.environ.get('appid')
robot.config["APP_SECRET"] = os.environ.get('secret')

# @robot.handler 處理所有消息
@robot.handler
def hello(message):
    return 'Hello World!'

if __name__ == "__main__":
    # 讓服務器監聽在 0.0.0.0:80
    robot.config['HOST'] = '0.0.0.0'
    robot.config['PORT'] = 80
    robot.run()


在本地安裝werobot相關依賴,執行部署:

把下面地址複製到公衆號後臺:

開啓調用即可。

參考Git:https://github.com/serverless-tencent/tencent-werobot

這裏需要注意的是,我們一定要關掉Session或者將Session改成雲數據庫,不能使用本地文件等,例如關閉Session配置:

robot.config['SESSION_STORAGE'] = False

文本相似度實現圖文檢索

首先要說爲什麼要做文章搜索功能?因爲用戶不知道我們發了什麼文章,也不清楚每個文章具體內容,他可能只需要簡單的關鍵詞來看一下這個公衆號是否有他想要的東西,例如用戶搜索“如何上傳文件?”這樣類似的簡單問題,我們就可以快速把最相關的歷史文章推送給用戶。

先預覽一下效果圖:

通過這樣簡單的問題描述找到目標結果,表面上這是一個文章搜索功能,實際上可以把它拓展成是一種“客服系統”,甚至將其升級爲一種“聊天系統”。

在之前的代碼基礎上,我們新增兩個函數:

  • 函數1: 索引建立函數

主要功能:通過觸發該函數,將現有的公衆號數據進行整理,並且建立適當的索引文件,存儲到COS中。


# -*- coding: utf8 -*-
import os
import re
import json
import random
from snownlp import SnowNLP
from qcloud_cos_v5 import CosConfig
from qcloud_cos_v5 import CosS3Client

bucket = os.environ.get('bucket')
secret_id = os.environ.get('secret_id')
secret_key = os.environ.get('secret_key')
region = os.environ.get('region')
client = CosS3Client(CosConfig(Region=region, SecretId=secret_id, SecretKey=secret_key))


def main_handler(event, context):
    response = client.get_object(
        Bucket=bucket,
        Key=event["key"],
    )
    response['Body'].get_stream_to_file('/tmp/output.txt')

    with open('/tmp/output.txt') as f:
        data = json.loads(f.read())

    articlesIndex = []
    articles = {}
    tempContentList = [
        "_", "&nbsp;",
    ]
    for eveItem in data:
        for i in range(0, len(eveItem['content']['news_item'])):
            content = eveItem['content']['news_item'][i]['content']
            content = re.sub(r'<code(.*?)</code>', '_', content)
            content = re.sub(r'<.*?>', '', content)
            for eve in tempContentList:
                content = content.replace(eve, "")
            desc = "%s。%s。%s" % (
                eveItem['content']['news_item'][i]['title'],
                eveItem['content']['news_item'][i]['digest'],
                "。".join(SnowNLP(content).summary(3))
            )
            tempKey = "".join(random.sample('zyxwvutsrqponmlkjihgfedcba', 5))
            articlesIndex.append(
                {
                    "media_id": tempKey,
                    "description": desc
                }
            )
            articles[tempKey] = eveItem['content']['news_item'][i]

    client.put_object(
        Bucket=bucket,
        Body=json.dumps(articlesIndex).encode("utf-8"),
        Key=event['index_key'],
        EnableMD5=False
    )
    client.put_object(
        Bucket=bucket,
        Body=json.dumps(articles).encode("utf-8"),
        Key=event['key'],
        EnableMD5=False
    )

這一部分定製化可能比較多一些,首先是tempContentList變量,可以寫上一些公衆號中重複且不重要的話,例如在公衆號開始結尾可能有歡迎關注的文案,這些文案理論上不應該參與搜索,所以最好在建立索引的時候就替換去除。然後我們還通過上述代碼去掉了code標籤裏面的內容,因爲代碼也會影響結果,同時也去掉了html標籤。

原始的文件大概是這樣的:

處理好的文件(通過標題+描述+SnowNLP提取的摘要):

然後將這些文件存儲到COS中,這一部分的核心就是保證提取出來的description儘可能地可以準確描述文章的內容。一般情況下,標題就是文章的核心,但是標題可能有一些信息丟失,例如說文章:【想法】用騰訊雲Serverless你要知道他們兩個的區別實際上描述的是Plugin和Component的區別,雖然標題知道是兩個東西,但是卻缺少了核心的目標,所以再加上我們下面的描述:什麼是Serverless Framework Plugin?什麼是Component?Plugin與Component有什麼區別?想要入門Serverless CLI,這兩個產品必須分的清楚,本文將會分享這二者區別與對應的特點、功能。當然,加上描述之後內容變得已經相當精確,但是正文中,可能有相對來說更加精準的描述或者額外的內容,所以採用的是標題+描述+摘要(textRank提取出來的前三句,屬於提取式文本)。

  • 函數2: 搜索函數

主要功能:當用戶向微信號發送了指定關鍵詞,通過該函數獲取的結果。

思考:函數1和函數2,都可以集成在之前的函數中,爲什麼要把函數1和函數2單獨拿出來做一個獨立的函數存在呢?放在一個函數中不好麼?

是這樣的,主函數觸發次數相對來說是最多的,而且這個函數本身不需要太多的資源配置(64M就夠了),而函數1和函數2,可能需要消耗更多的資源,如果三個函數合併放在一起,可能函數的內存大小需要整體調大,滿足三個函數需求,這樣的話,相對來說會消耗更多資源,例如
主函數觸發了10次(64M,每次1S),函數1觸發了2次(512M,每次5S),函數2觸發了4次(384M,每次3S)

如果將三個函數放在一起,資源消耗是:

如果將其變成三個函數來執行,資源消耗是:

前者總計資源消耗13308,後者10432,隨着調用次數越來越多,主函數的調用比例會越來越大,所以節約的資源也就會越來越多,所以此處建議將資源消耗差距比較大的模塊,分成不同函數進行部署。

import os
import json
import jieba
from qcloud_cos_v5 import CosConfig
from qcloud_cos_v5 import CosS3Client
from collections import defaultdict
from gensim import corpora, models, similarities

bucket = os.environ.get('bucket')
secret_id = os.environ.get('secret_id')
secret_key = os.environ.get('secret_key')
region = os.environ.get('region')
client = CosS3Client(CosConfig(Region=region, SecretId=secret_id, SecretKey=secret_key))


def main_handler(event, context):
    response = client.get_object(
        Bucket=bucket,
        Key=event["key"],
    )
    response['Body'].get_stream_to_file('/tmp/output.txt')

    with open('/tmp/output.txt') as f:
        data = json.loads(f.read())

    articles = []
    articlesDict = {}
    for eve in data:
        articles.append(eve['description'])
        articlesDict[eve['description']] = eve['media_id']

    sentence = event["sentence"]

    documents = []
    for eve_sentence in articles:
        tempData = " ".join(jieba.cut(eve_sentence))
        documents.append(tempData)
    texts = [[word for word in document.split()] for document in documents]
    frequency = defaultdict(int)
    for text in texts:
        for word in text:
            frequency[word] += 1
    dictionary = corpora.Dictionary(texts)
    new_xs = dictionary.doc2bow(jieba.cut(sentence))
    corpus = [dictionary.doc2bow(text) for text in texts]
    tfidf = models.TfidfModel(corpus)
    featurenum = len(dictionary.token2id.keys())
    sim = similarities.SparseMatrixSimilarity(
        tfidf[corpus],
        num_features=featurenum
    )[tfidf[new_xs]]
    answer_list = [(sim[i], articles[i]) for i in range(1, len(articles))]
    answer_list.sort(key=lambda x: x[0], reverse=True)
    result = []
    print(answer_list)
    for eve in answer_list:
        if eve[0] > 0.10:
            result.append(articlesDict[eve[1]])
    if len(result) >= 8:
        result = result[0:8]
    return {"result": json.dumps(result)}

這一部分的代碼也是很簡單,主要是通過文本的相似度對每個文本進行評分,然後按照評分從高到低進行排序,給定一個閾值(此處設定的閾值爲0.1),輸出閾值之前的數據。

另外這裏要注意,此處引用了兩個依賴是jieba和gensim,這兩個依賴都可能涉及到二進制文件,所以強烈推薦在CentOS系統下進行打包。

接下來就是主函數中的調用,爲了實現上述功能,需要在主函數中新增方法:

1: 獲取全部圖文消息

def getTheTotalOfAllMaterials():
    '''
    文檔地址:https://developers.weixin.qq.com/doc/offiaccount/Asset_Management/Get_the_total_of_all_materials.html
    :return:
    '''
    accessToken = getAccessToken()
    if not accessToken:
        return "Get Access Token Error"
    url = "https://api.weixin.qq.com/cgi-bin/material/get_materialcount?access_token=%s" % accessToken
    responseAttr = urllib.request.urlopen(url=url)
    return json.loads(responseAttr.read())


def getMaterialsList(listType, count):
    '''
    文檔地址:https://developers.weixin.qq.com/doc/offiaccount/Asset_Management/Get_materials_list.html
    :return:
    '''
    accessToken = getAccessToken()
    if not accessToken:
        return "Get Access Token Error"

    url = "https://api.weixin.qq.com/cgi-bin/material/batchget_material?access_token=%s" % accessToken
    materialsList = []
    for i in range(1, int(count / 20) + 2):
        requestAttr = urllib.request.Request(url=url, data=json.dumps({
            "type": listType,
            "offset": 20 * (i - 1),
            "count": 20
        }).encode("utf-8"), headers={
            "Content-Type": "application/json"
        })
        responseAttr = urllib.request.urlopen(requestAttr)
        responseData = json.loads(responseAttr.read().decode("utf-8"))
        materialsList = materialsList + responseData["item"]
    return materialsList

可以通過以下代碼調用:

rticlesList = getMaterialsList("news", getTheTotalOfAllMaterials()['news_count'])

2: 將圖文消息存儲到COS,並且通過函數的Invoke接口,實現函數間調用:

def saveNewsToCos():
    global articlesList
    articlesList = getMaterialsList("news", getTheTotalOfAllMaterials()['news_count'])
    try:
        cosClient.put_object(
            Bucket=bucket,
            Body=json.dumps(articlesList).encode("utf-8"),
            Key=key,
            EnableMD5=False
        )
        req = models.InvokeRequest()
        params = '{"FunctionName":"Weixin_GoServerless_GetIndexFile", "ClientContext":"{\\"key\\": \\"%s\\", \\"index_key\\": \\"%s\\"}"}' % (
            key, indexKey)
        req.from_json_string(params)
        resp = scfClient.Invoke(req)
        resp.to_json_string()
        response = cosClient.get_object(
            Bucket=bucket,
            Key=key,
        )
        response['Body'].get_stream_to_file('/tmp/content.json')
        with open('/tmp/content.json') as f:
            articlesList = json.loads(f.read())
        return True
    except Exception as e:
        print(e)
        return False

3: 根據搜索反饋回來的Key實現文章內容的對應

def searchNews(sentence):
    req = models.InvokeRequest()
    params = '{"FunctionName":"Weixin_GoServerless_SearchNews", "ClientContext":"{\\"sentence\\": \\"%s\\", \\"key\\": \\"%s\\"}"}' % (
        sentence, indexKey)
    req.from_json_string(params)
    resp = scfClient.Invoke(req)
    print(json.loads(json.loads(resp.to_json_string())['Result']["RetMsg"]))
    media_id = json.loads(json.loads(json.loads(resp.to_json_string())['Result']["RetMsg"])["result"])
    return media_id if media_id else None

最後在main_handler中,增加使用邏輯:

邏輯很簡單,就是根據用戶發的消息去查找對應的結果,拿到結果之後判斷結果個數,如果有1個相似內容,則返回一個圖文,如果有多個則返回帶有鏈接的文本。

另外一個邏輯是建立索引,直接通過API網關觸發即可,當然,如果怕不安全或者有需要的話,可以增加權限鑑定的參數:

額外優化:

在接口列表中,我們可以看到獲取accessToken的接口實際上是有次數限制的,每次獲取有效期兩個小時。所以,我們就要在函數中對這部分內容做持久化。爲了實現這個功能,使用MySQL貌似不是很划算,所以我們決定用COS:

def getAccessToken():
    '''
    文檔地址:https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Get_access_token.html
    正常返回:{"access_token":"ACCESS_TOKEN","expires_in":7200}
    異常返回:{"errcode":40013,"errmsg":"invalid appid"}
    :return:
    '''
    global accessToken

    # 第一次判斷是判斷本地是否已經有了accessToken,考慮到容器複用情況
    if accessToken:
        if int(time.time()) - int(accessToken["time"]) <= 7000:
            return accessToken["access_token"]

    # 如果本地沒有accessToken,可以去cos獲取
    try:
        response = cosClient.get_object(
            Bucket=bucket,
            Key=accessTokenKey,
        )
        response['Body'].get_stream_to_file('/tmp/token.json')
        with open('/tmp/token.json') as f:
            accessToken = json.loads(f.read())
    except:
        pass

    # 這一次是看cos中是否有,如果cos中有的話,再次進行判斷段
    if accessToken:
        if int(time.time()) - int(accessToken["time"]) <= 7000:
            return accessToken["access_token"]

    # 如果此時流程還沒停止,則說明accessToken還沒獲得到,就需要從接口獲得,並且同步給cos
    url = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s" % (appid, secret)
    accessTokenResult = json.loads(urllib.request.urlopen(url).read().decode("utf-8"))
    accessToken = {"time": int(time.time()), "access_token": accessTokenResult["access_token"]}
    print(accessToken)
    response = cosClient.put_object(
        Bucket=bucket,
        Body=json.dumps(accessToken).encode("utf-8"),
        Key=accessTokenKey,
        EnableMD5=False
    )
    return None if "errcode" in accessToken else accessToken["access_token"]

當然,我覺得這段代碼可以繼續優化,但是目前這個算是一個思路。

爲公衆號增加機器人功能

上文我們已經完成了公衆號的基本框架的搭建,也完成了基於NLP知識的圖文檢索功能,可以說之前的內容都是原生開發,無論是公衆號基礎能力建設還是圖文檢索能力,而現在我們將在之前的基礎上,通過雲服務商提供的AI能力,將智能聊天功能接入其中。

首先假設一個場景:用戶關注這個公衆號之後,他給公衆號發送文本消息,我們首先進行圖文檢索,如果沒找到合適的結果,就默認進入“聊天功能”;如果用戶發送了語音,我們同樣先進行圖文檢索,如果沒有找得到相似圖文,則通過語音進入“聊天功能”,這樣看來是不是整個功能變得非常有趣?

首先整體看一下機器人功能的基本形態:

聊天功能增加

聊天功能我們可以藉助雲廠商提供的聊天機器人服務:

開通和使用這個服務,可以爲我們創建一個簡單的機器人:

創建完成機器人,我們可以通過雲API對其進行代碼的編寫,雲API代碼比較難寫也不怕,有API Explorer,系統會爲我們自動編寫好基本的代碼,我們只需要稍加修改,就可以複製到項目中。

在最外層進行相關初始化:

tbpClient = tbp_client.TbpClient(credential.Credential(secret_id, secret_key), region)

初始化完成,增加聊天機器人函數:

def chatBot(user, content):
    '''
    開發文檔:https://cloud.tencent.com/document/product/1060/37438
    :param user: 用戶id
    :param content: 聊天內容
    :return: 返回機器人說的話,如果出現故障返回None
    '''
    try:
        req = tbp_models.TextProcessRequest()
        params = '{"BotId":"%s","BotEnv":"release","TerminalId":"%s","InputText":"%s"}' % (
            bot_id, user, content
        )
        req.from_json_string(params)
        resp = tbpClient.TextProcess(req)
        return json.loads(resp.to_json_string())['ResponseMessage']['GroupList'][0]['Content']
    except Exception as e:
        print(e)
        return None

文本轉音頻功能增加

同樣的方法,這不過是使用的另一個產品:

同樣通過Explorer編寫代碼,然後初始化:

ttsClient = tts_client.TtsClient(credential.Credential(secret_id, secret_key), region)

增加相關的方法實現文本到函數的轉換:

def text2Voice(text):
    '''
    文檔地址:https://cloud.tencent.com/document/product/1073/37995
    :param text: 帶轉換的文本
    :return: 返回轉換後的文件地址
    '''
    try:
        req = tts_models.TextToVoiceRequest()
        params = '{"Text":"%s","SessionId":"%s","ModelType":1,"VoiceType":1002}' % (
            text, "".join(random.sample('zyxwvutsrqponmlkjihgfedcba', 7)))
        req.from_json_string(params)
        resp = ttsClient.TextToVoice(req)
        file = '/tmp/' + "".join(random.sample('zyxwvutsrqponmlkjihgfedcba', 7)) + ".wav"
        with open(file, 'wb') as f:
            f.write(base64.b64decode(json.loads(resp.to_json_string())["Audio"]))
        return file

    except Exception as e:
        print(e)
        return None

增加微信的素材相關邏輯

由於我的賬號是未認證的訂閱號,所以可以使用的功能有限。在這裏我需要先將生成的語音素材上傳到公衆號後臺作爲永久素材。因爲語音類素材最大量爲1000個,所以我還要順便刪除多餘的素材。

此處我的做法很簡單,先上傳素材,然後獲得素材總數,接下來根據素材中的時間戳:

{
	'media_id': 'HQOG98Gpaa4KcvU1L0MPEW4Zvngs4kBqOyTRzNWBNME', 
	'name': 'ljpmybc.wav',
	'update_time': 1582896372, 
	'tags': []
}

就是update_time這個參數,和現在的時間進行判斷,超過60S則認爲這個素材已經過期,就可以刪除,這樣保證我們的素材數量不會溢出:

增加永久素材:

def addingOtherPermanentAssets(file, fileType):
    '''
    文檔地址:https://developers.weixin.qq.com/doc/offiaccount/Asset_Management/Adding_Permanent_Assets.html
    返回結果:{
                "media_id":"HQOG98Gpaa4KcvU1L0MPEcyy31LSuHhRi8gD3pvebhI",
                "url":"http:\/\/mmbiz.qpic.cn\/sz_mmbiz_png\/icxY5TTGTBibSyZPfLAEZmeaicUczsoGUpqLgBlRbNxeic4R8r94j60BiaxDLEZTAK7I7qubG3Ik808P8jYLdFJTcOA\/0?wx_fmt=png",
                "item":[]
            }
    :param file:
    :return:
    '''
    typeDict = {
        "voice": "wav"
    }
    url = "https://api.weixin.qq.com/cgi-bin/material/add_material?access_token=%s&type=%s" % (
        getAccessToken(), fileType)
    boundary = '----WebKitFormBoundary7MA4YWxk%s' % "".join(random.sample('zyxwvutsrqponmlkjihgfedcba', 7))
    with open(file, 'rb') as f:
        fileData = f.read()
    data = {'media': (os.path.split(file)[1], fileData, typeDict[fileType])}
    headers = {
        "Content-Type": "multipart/form-data; boundary=%s" % boundary,
        "User-Agent": "okhttp/3.10.0"
    }
    reqAttr = urllib.request.Request(url=url,
                                     data=encode_multipart_formdata(data, boundary=boundary)[0],
                                     headers=headers)
    responseData = json.loads(urllib.request.urlopen(reqAttr).read().decode("utf-8"))

    try:
        for eveVoice in getMaterialsList("voice", getTheTotalOfAllMaterials()['voice_count']):
            try:
                if int(time.time()) - int(eveVoice["update_time"]) > 60:
                    deletingPermanentAssets(eveVoice['media_id'])
            except:
                pass
    except:
        pass

    return responseData['media_id'] if "media_id" in responseData else None

刪除素材:

def deletingPermanentAssets(media_id):
    '''
    文檔地址:https://developers.weixin.qq.com/doc/offiaccount/Asset_Management/Deleting_Permanent_Assets.html
    :return:
    '''
    url = 'https://api.weixin.qq.com/cgi-bin/material/del_material?access_token=%s' % (getAccessToken())
    data = {
        "media_id": media_id
    }
    postData = json.dumps(data).encode("utf-8")
    reqAttr = urllib.request.Request(url=url, data=postData)
    print(urllib.request.urlopen(reqAttr).read())

至此,基礎代碼已經完成,剩下的邏輯就是在main_handler中進行組合:

文本消息部分的組合邏輯:

media_id = searchNews(event["Content"])
result = getNewsResult(media_id, event)
if not result:
	chatBotResponse = chatBot(event["FromUserName"], event["Content"])
	result = textXML({"msg": chatBotResponse if chatBotResponse else "目前還沒有類似的文章被髮布在這個公衆號上"}, event)
	return response(body=result)

語音消息部分組合邏輯:

media_id = searchNews(event["Recognition"])
result = getNewsResult(media_id, event)
if not result:
    chatBotResponse = chatBot(event["FromUserName"], event["Recognition"])
    if chatBotResponse:
        voiceFile = text2Voice(chatBotResponse)
        if voiceFile:
            uploadResult = addingOtherPermanentAssets(voiceFile, 'voice')
            if uploadResult:
                result = voiceXML({"media_id": uploadResult}, event)
if not result:
    result = textXML({"msg": "目前還沒有類似的文章被髮布在這個公衆號上"}, event)
return response(body=result)

總結

至此,我們完成了一個簡單的公衆號開發。通過Serverless的原生開發思路(也可以使用Werobot等公衆號開發框架),將公衆號後臺服務部署到Serverless架構上,通過自然語言處理技術(特指文本相似度等)實現了一個圖文檢索功能;通過與雲廠商提供的AI能力結合,實現了一個聊天機器人,可以進行文本交流,也可以進行語音溝通。

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