選單
GSS 技術部落格
在這個園地裡我們將從技術、專案管理、客戶對談面和大家分享我們多年的經驗,希望大家不管是喜歡或是有意見,都可以回饋給我們,讓我們有機會和大家對話並一起成長!
若有任何問題請來信:gss_crm@gss.com.tw
10 分鐘閱讀時間 (2070 個字)

微信訊息的 Reply 與 Push

wechat

微信 vs. WeChat

原以為 WeChat 只是微信的英譯,但上網查了一下資料後,發現其實不然,簡而言之,微信主要針對中國大陸市場,而 WeChat 則針對國際市場。而我們要開發 Chatbot 就必須根據不同市場註冊類似於 LINE@ 的 「微信公眾平台 」或是「WeChat Official Account」,他們的技術文件也有著些微的差異,前者有微信公眾平台技術文件,後者有 WeChat Message API

平台方

  • 「微信公眾平台 」能向微信及 WeChat 使用者推播訊息
  • 「WeChat Official Account」能向 WeChat 使用者推播訊息,但不能向微信使用者推播訊息

使用者方

  • 微信使用者能瀏覽「微信公眾平台 」及「WeChat Official Account」的內容;可以參與前者活動,但不能參與後者
  • WeChat 使用者能瀏覽「微信公眾平台 」及「WeChat Official Account」的內容;兩者活動皆可參與

接口測試帳號

如果目標客群是在中國大陸,註冊微信公眾平台時必須填寫中國大陸的身分資訊,而開發者可以先透過「微信公眾平台接口測試帳號申請」註冊一個測試帳號,並且在接口配置的地方綁定我們的 Chatbot Server 作為溝通的接口,此時公眾平台就會發送一個 GET Request 到我們的 Chatbot Server 進行 URL 的驗證 (類似於 LINE 的 Webhook URL 綁定),相關配置及驗證細節可以直接參考亂馬客大神的詳細解說,這邊就不再贅述

回覆訊息 (Reply)

當微信使用者向公眾平台發送訊息時,公眾平台會 POST 一個 XML 結構的資料流到我們的 Chatbot Server,例如:使用者發送了「你好」的文字 (text) 訊息給公眾平台,我們接收到的 XML 結構為

 

<xml>
  <ToUserName><![CDATA[公眾平台 ID]]></ToUserName>
  <FromUserName><![CDATA[使用者 Open ID]]</FromUserName>
  <CreateTime>1348831860</CreateTime>
  <MsgType><![CDATA[text]]></MsgType>
  <Content><![CDATA[你好]]></Content>
  <MsgId>1234567890123456</MsgId>
</xml>

 

此時開發者要立即 Response 另一組特定的 XML 結構來對該訊息進行一次性的回覆,例如:若要繼續回覆文字 (text) 訊息,我們可以組出如下的 XML

<xml>
  <ToUserName><![CDATA[使用者 Open ID]]></ToUserName>
  <FromUserName><![CDATA[公眾平台 ID]]</FromUserName>
  <CreateTime>12345678</CreateTime>
  <MsgType><![CDATA[text]]></MsgType>
  <Content><![CDATA[您輸入了:你好]]></Content>
</xml>

然後 Response 輸出。程式中,我們會定義一個 Data Transfer Object WxReceivedTextMessage 用來裝填接收到的 XML 結構,部分的程式碼如下

 

WxReceivedTextMessage receivedTextMessage = new WxReceivedTextMessage(receivedMessage.Xml);

string replyContent = string.Format("您輸入了:{0}", receivedTextMessage.TextMessage.Content);

string replyXml = receivedTextMessage.CreateReplyXml(replyContent);

return Request.CreateResponse(replyXml);

 

除了文字 (text) 訊息,也有圖片 (image)、語音 (voice)、視頻 (video)、音樂 (music) 及圖文 (news) 等多種回覆訊息格式供開發者依需求來使用。另外要注意的是,若 5 秒內都沒有 Response,公眾平台會先切斷連線,然後再重新 發出 POST Request,最多會嘗試 3 次

若開發者不能保證 5 秒內給予 Response,且又不希望公眾平台作任何處理,則可以 Response 一個空字串 (非 XML)

而當使用者發出訊息後,畫面若出現「该公众号暂时无法提供服务,请稍后再试」或是「The official account cannot provide service currently, please try again later」的字樣,則表示

  • Chatbot Server 5 秒內沒進行 Response
  • Response 不正確的結構,如:JSON


好,以上就是回覆訊息的部分。至於 Chatbot Server 我們會以 Web API 的方式提供 Web Service,作為與公眾平台進行驗證 (Authenticate) 與回覆 (Reply) 的接口,完整的 Sample Code 參考如下

 

using NLog;
using System;
using System.Collections;
using System.Net;
using System.Net.Http;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using System.Web.Configuration;
using System.Web.Http;
using System.Xml;
[RoutePrefix("api/wechat")]
public class WeChatBotController : ApiController
{
    private static Logger logger = LogManager.GetCurrentClassLogger();
    [Route]
    [HttpPost]
    public async Task<HttpResponseMessage> Reply()
    {
        try
        {
            logger.Debug("Action is [Reply]");
            string xml = await Request.Content.ReadAsStringAsync();
            WxReceivedMessage receivedMessage = new WxReceivedMessage(xml);
            logger.Debug(string.Format("Received xml format message: {0}", xml));
            switch (receivedMessage.MsgType)
            {
                case WxReceivedMessage.Text:
                    WxReceivedTextMessage receivedTextMessage = new WxReceivedTextMessage(receivedMessage.Xml);
                    string replyContent = string.Format("您輸入了:{0}", receivedTextMessage.TextMessage.Content);
                    string replyXml = receivedTextMessage.CreateReplyXml(replyContent);
                    logger.Debug(string.Format("Reply xml format message: {0}", replyXml));
                    return Request.CreateResponse(replyXml);
                default:
                    return Request.CreateErrorResponse(HttpStatusCode.UnsupportedMediaType, "Message type not support");
            }
        }
        catch (Exception ex)
        {
            logger.Error(ex);
            return Request.CreateErrorResponse(HttpStatusCode.InternalServerError, ex.Message);
        }
    }
    [Route]
    [HttpGet]
    public HttpResponseMessage Authenticate([FromUri] WxSignatureParameter signatureParameter)
    {
        try
        {
            logger.Debug("Action is [Authenticate]");
            logger.Debug("Need to verify validity of the URL");
            if (CheckSignature(signatureParameter))
            {
                logger.Debug("URL is valid");
                string echostr = signatureParameter.Echostr;
                logger.Debug(string.Format("Return echostr: {0}", echostr));
                return new HttpResponseMessage() { Content = new StringContent(echostr, UTF8Encoding.UTF8, "application/x-www-form-urlencoded") };
            }
            else
            {
                logger.Debug("Authentication fail");
                return Request.CreateErrorResponse(HttpStatusCode.ProxyAuthenticationRequired, "Fail to authenticate");
            }
        }
        catch (Exception ex)
        {
            logger.Error(ex);
            return Request.CreateErrorResponse(HttpStatusCode.InternalServerError, ex.Message);
        }
    }
    private bool CheckSignature(WxSignatureParameter signatureParameter)
    {
        string token = WebConfigurationManager.AppSettings["Token"];
        string signature = signatureParameter.Signature;
        string timestamp = signatureParameter.Timestamp;
        string nonce = signatureParameter.Nonce;
        ArrayList list = new ArrayList
        {
            token,
            timestamp,
            nonce
        };
        list.Sort(new WxComparer());
        string raw = "";
        for (int i = 0; i < list.Count; ++i)
        {
            raw += list[i];
        }
        string hash = "";
        SHA1 sha = new SHA1CryptoServiceProvider();
        ASCIIEncoding enc = new ASCIIEncoding();
        byte[] dataToHash = enc.GetBytes(raw);
        byte[] dataHashed = sha.ComputeHash(dataToHash);
        hash = BitConverter.ToString(dataHashed).Replace("-", "");
        hash = hash.ToLower();
        return signature == hash;
    }
}
public class WxReceivedMessage
{
    public const string Text = "text";
    public const string Image = "image";
    public const string Audio = "voice";
    public const string Video = "video";
    public const string LocationData = "location";
    public const string Link = "link";
    public WxReceivedMessage(XmlDocument xml)
    {
        Xml = xml;
        Init();
    }
    public WxReceivedMessage(string xml)
    {
        Xml = new XmlDocument();        
        Xml.LoadXml(xml);
        Init();
    }
    public XmlDocument Xml { get; set; }
    /// <summary>
    /// WeChat ID of your app
    /// </summary>
    public string ToUserName { get; set; }
    /// <summary>
    /// a unique ID for the sender
    /// </summary>
    public string FromUserName { get; set; }
    /// <summary>
    /// create time of the message
    /// </summary>
    public int CreateTime { get; set; }
    /// <summary>
    /// a unique ID for the message (64 bit integer)
    /// </summary>
    public string MsgId { get; set; }
    /// <summary>
    /// message type ("text" for text messages)
    /// </summary>
    public virtual string MsgType { get; set; }
    private void Init()
    {
        XmlNode toUserName = Xml.SelectSingleNode("/xml/ToUserName");
        XmlNode fromUserName = Xml.SelectSingleNode("/xml/FromUserName");
        XmlNode createTime = Xml.SelectSingleNode("/xml/CreateTime");
        XmlNode msgId = Xml.SelectSingleNode("/xml/MsgId");
        XmlNode msgType = Xml.SelectSingleNode("/xml/MsgType");
        ToUserName = toUserName.InnerText;
        FromUserName = fromUserName.InnerText;
        CreateTime = Convert.ToInt32(createTime.InnerText);
        MsgId = msgId.InnerText;
        MsgType = msgType.InnerText;
    }
}
public class WxReceivedTextMessage : WxReceivedMessage
{
    private const string ReplyPattern = @"<xml><ToUserName><![CDATA[{0}]]></ToUserName><FromUserName><![CDATA[{1}]]></FromUserName><CreateTime>{2}</CreateTime><MsgType><![CDATA[text]]></MsgType><Content><![CDATA[{3}]]></Content></xml>";
    public WxReceivedTextMessage(XmlDocument xml) : base(xml)
    {
        Init();
    }
    public WxReceivedTextMessage(string xml) : base(xml)
    {
        Init();
    }
    public override string MsgType
    {
        get { return Text; }
    }
    public WxTextMessage TextMessage { get; set; }
    public string CreateReplyXml(WxTextMessage textMessage)
    {
        return CreateReplyXml(textMessage.Content);
    }
    public string CreateReplyXml(string content)
    {
        return string.Format(
            ReplyPattern,
            FromUserName,
            ToUserName,
            DateTime.UtcNow.Ticks,
            content);
    }
    private void Init()
    {
        XmlNode content = Xml.SelectSingleNode("/xml/Content");
        TextMessage = new WxTextMessage(content.InnerText);
    }
}
public class WxTextMessage
{
    public WxTextMessage(string content)
    {
        Content = content;
    }
    /// <summary>
    /// Message content
    /// </summary>
    public string Content { get; set; }
}

public class WxSignatureParameter
{
    public string Signature { get; set; }
    public string Timestamp { get; set; }
    public string Nonce { get; set; }
    public string Echostr { get; set; }
}
public class WxComparer : IComparer
{
    public int Compare(object oLeft, object oRight)
    {
        string sLeft = oLeft as string;
        string sRight = oRight as string;
        int iLeftLength = sLeft.Length;
        int iRightLength = sRight.Length;
        int index = 0;
        while (index < iLeftLength && index < iRightLength)
        {
            if (sLeft[index] < sRight[index])
                return -1;
            else if (sLeft[index] > sRight[index])
                return 1;
            else
                index++;
        }
        return iLeftLength - iRightLength;
    }
}

 

以下為執行結果




推播訊息 (Push)

既然有被動地回覆訊息,當然也可以主動地推播訊息給使用者,然而我們在使用任何服務前要先經過 OAuth 2.0 的授權 (Authorization),從接口測試帳號我們可以取得 App ID 及 App Secret,然後呼叫公眾平台的 API,發出 GET Request

 

https Request Method: GEThttps://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={APP_ID}&secret={APP_SECRET}

 

取回一組 JSON,屬性裡有我們需要的 Access Token,也有 Expire Time 可以利用,單位為 (秒)

 

{"access_token":"ACCESS_TOKEN","expires_in":7200}

 

再來,我們要組出欲推播的訊息結構,這邊不再以 XML 來作為資料交換的格式,而是以 JSON。另外,推播也具備多種訊息格式,例如:文字 (text)、圖片 (image)、語音 (voice)、視頻 (video)、音樂 (music)、點擊跳轉到外部連結的圖文 (news) 及點擊跳轉到圖文訊息頁面的圖文 (mpnews),也有更進階的菜單 (msgmenu)、卡券 (wxcard) 及小程序卡片 (miniprogrampage) 等格式供開發者使用,這邊我們同樣先以文字 (text) 訊息為例

 

{
   "touser":"OPEN_ID",
   "msgtype":"text",
   "text":
   {
        "content":"推播測試"
   }
}

 

透過公眾平台的 API,以 Access Token 作為參數,並夾帶著 JSON Body 發出 POST Request,這樣就能推播訊息了

 

HTTP Request Method: POST
https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token={ACCESS_TOKEN}

 

我寫了一個簡單的 Console,參考如下

using Newtonsoft.Json;
using System;
using System.Configuration;
using System.IO;
using System.Net;
using System.Text;
class Program
{
    static void Main(string[] args)
    {
        StringBuilder sb = new StringBuilder();       sb.AppendLine("====================================================");
        sb.AppendLine("          -- WeChat Push Message Sample --          ");       sb.AppendLine("====================================================");
        Console.WriteLine(sb.ToString());
        string openId = ConfigurationManager.AppSettings["TestOpenId"];
        Console.WriteLine("Input The Message You Want to Push:");
        string input = Console.ReadLine();
        Console.WriteLine(string.Format("\nYou Input {0}\n", input));
        WxPushTextMessage pushTextMessage = new WxPushTextMessage
        {
            ToUser = openId,
            TextMessage = new WxTextMessage(input)
        };
        WeChatBotChannel channel = new WeChatBotChannel();
        channel.Push(pushTextMessage);
        Console.WriteLine("Press Any Key to Exit...");
        Console.ReadKey();
    }
}
public class WeChatBotChannel
{
    private const string BaseUrl = "https://api.weixin.qq.com/cgi-bin/";
    private const string Token = "token?grant_type=client_credential&appid={0}&secret={1}";
    private const string PushMessage = "message/custom/send?access_token={0}";
    public void Push(WxPushMessage pushMessage)
    {
        try
        {
            WxAuthToken authToken = Authorize();
            Console.WriteLine("Start to Push Message...\n");
            string serviceUrl = BaseUrl + string.Format(PushMessage, authToken.AccessToken);
            Console.WriteLine(string.Format("[POST]\nRequest Url:\n{0}\n", serviceUrl));
            string requestBody = JsonConvert.SerializeObject(pushMessage);
            Console.WriteLine(string.Format("Request Body:\n{0}\n", requestBody));
            HttpWebRequest request = (HttpWebRequest)WebRequest.Create(serviceUrl);
            request.Method = WebRequestMethods.Http.Post;
            request.ContentType = "application/json";
            byte[] postData = Encoding.UTF8.GetBytes(requestBody);
            using (Stream stream = request.GetRequestStream())
            {
                stream.Write(postData, 0, postData.Length);
            }
            using (WebResponse response = request.GetResponse())
            {
                using (StreamReader reader = new StreamReader(response.GetResponseStream()))
                {
                    Console.WriteLine(string.Format("Push Message Response:\n{0}\n", reader.ReadToEnd()));
                }
            }
        }
        catch (Exception ex)
        {
            Console.WriteLine(ex);
        }
    }
    private WxAuthToken Authorize()
    {
        Console.WriteLine("Start to Authorize...\n");
        string appId = ConfigurationManager.AppSettings["AppId"];
        string appSecret = ConfigurationManager.AppSettings["AppSecret"];
        string serviceUrl = BaseUrl + string.Format(Token, appId, appSecret);
        Console.WriteLine(string.Format("[GET]\nRequest Url:\n{0}\n", serviceUrl));
        HttpWebRequest request = (HttpWebRequest)WebRequest.Create(serviceUrl);
        request.Method = WebRequestMethods.Http.Get;
        using (WebResponse response = request.GetResponse())
        {
            using (StreamReader reader = new StreamReader(response.GetResponseStream()))
            {
                string result = reader.ReadToEnd();
                Console.WriteLine(string.Format("Authorize Response:\n{0}\n", result));
                return JsonConvert.DeserializeObject<WxAuthToken>(result);
            }
        }
    }
}
[JsonObject(MemberSerialization.OptIn)]
public class WxAuthToken
{
    [JsonProperty(PropertyName = "access_token")]
    public string AccessToken { get; set; }
    [JsonProperty(PropertyName = "expires_in")]
    public string ExpiresIn { get; set; }
}
[JsonObject(MemberSerialization.OptIn)]
public class WxPushMessage
{
    [JsonProperty(PropertyName = "msgtype")]
    public virtual string MessageType { get; set; }
    /// <summary>
    /// User's OpenID
    /// </summary>
    [JsonProperty(PropertyName = "touser")]
    public string ToUser { get; set; }
}
[JsonObject(MemberSerialization.OptIn)]
public class WxPushTextMessage : WxPushMessage
{
    private const string _msgType = "text";
    [JsonProperty(PropertyName = "msgtype")]
    public override string MessageType
    {
        get { return _msgType; }
    }
    [JsonProperty(PropertyName = "text")]
    public WxTextMessage TextMessage { get; set; }
}
[JsonObject(MemberSerialization.OptIn)]
public class WxTextMessage
{
    public WxTextMessage(string content)
    {
        Content = content;
    }
    /// <summary>
    /// Message content
    /// </summary>
    [JsonProperty(PropertyName = "content")]
    public string Content { get; set; }
}

執行結果如下



參考資料

微信和 WeChat 的分別原來這樣大!搞不懂隨時流失 6 億客戶!
微信公眾號 vs. WeChat 官方帳號 傻傻分不清
微信公眾平台技術文件
WeChat Message API
asp.net web api 集成微信服务 (使用 Senparc 微信 SDK)
WeChat 微信 Bot 開發 — 接口配置信息

2018年Open Source弱點Top 10 (下)
Docker簡介