原以為 WeChat 只是微信的英譯,但上網查了一下資料後,發現其實不然,簡而言之,微信主要針對中國大陸市場,而 WeChat 則針對國際市場。而我們要開發 Chatbot 就必須根據不同市場註冊類似於 LINE@ 的 「微信公眾平台 」或是「WeChat Official Account」,他們的技術文件也有著些微的差異,前者有微信公眾平台技術文件,後者有 WeChat Message API
平台方
使用者方
如果目標客群是在中國大陸,註冊微信公眾平台時必須填寫中國大陸的身分資訊,而開發者可以先透過「微信公眾平台接口測試帳號申請」註冊一個測試帳號,並且在接口配置的地方綁定我們的 Chatbot Server 作為溝通的接口,此時公眾平台就會發送一個 GET Request 到我們的 Chatbot Server 進行 URL 的驗證 (類似於 LINE 的 Webhook URL 綁定),相關配置及驗證細節可以直接參考亂馬客大神的詳細解說,這邊就不再贅述
當微信使用者向公眾平台發送訊息時,公眾平台會 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 我們會以 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;
}
}
以下為執行結果
既然有被動地回覆訊息,當然也可以主動地推播訊息給使用者,然而我們在使用任何服務前要先經過 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 開發 — 接口配置信息