给微信公众号加个 Bot

Bot,会话助手类工具,一直有着相当广泛的应用。最早的 Bot 可能类似电信的电话查询系统:“查询账单请按 1,修改密码请按 2……”。这种机械式的应用渐渐被更为智能的对话助理所代替,典型代表有 Siri 和 Cortana 这种个人助理类应用。对于个人和企业,如何开发一个具有一定业务能力的 Bot,一直是一个比较热门的话题。在这篇博客中,我将记录如何基于 Microsoft Bot Framework 为微信公众号添加一个智能对话机器人。

背景

在商业环境中,一个 Bot 不仅要有趣,还要有业务能力。一个优秀的 Bot 要有自然语言识别能力、上下文处理能力和在对话中实现业务的能力。例如,如果要开发一个 Bot 来处理一家咖啡店的订座服务,这个 Bot 需要像服务员一样能够理解客户的语言,了解客户的意图,获取语句中的关键信息(时间,数量,地点等),最后用对话方式在前台完成业务。
Microsoft Bot Framework 提供了一套 Bot 开发的框架和工具,包括 Azure Bot Service,Bot Builder SDK 和相对丰富的 Bot Channels。这套工具可以帮助开发者在短时间内开发出一个个人或企业级 Bot。这套 Framework 可以轻松集成自然语言处理服务,认知服务并打通不同的沟通渠道(channels)。Bot Channels 目前支持了不少流行的社交和通信应用,因此开发者只要开发一套 Bot,就可以部署到不同的社交通信应用中,包括 Facebook Pages、Microsoft Teams、Skype、Slack 等等。

微信朋友圈和公众号作为一个非常特殊的社交网络,内容非常丰富,但生态也相对封闭。给公众号加个 Bot,让用户更自然地与公众号交流,说不定还挺好玩。目前 Bot Channels 并没有官方的支持微信和公众号 Channel。公众号本身提供了基于关键字的自动回复功能,但是这个选项没法实现更多的业务功能。另一方面,公众号同时也提供了开发接口,可以把用户输入的消息转发到设定的服务器上,这给集成 Bot 服务提供了可能。这里主要记录一下如何通过公众号开发接口来接入 Azure Bot Service。

公众号开发设置

首先需要申请注册公众号。然后接入微信公众平台开发,开发者需要按照如下步骤完成:填写服务器配置, 验证服务器地址的有效性, 依据接口文档实现业务逻辑

填写服务器配置

这里需要填写服务接口 URL,注意这里微信开发平台会且只会给这个 URL 发送 HTTP GET/POST 请求,开发者无法定义更多 API 接口。Token 可以随便填一个,在接下来的消息来源验证中会用到。EncodingAESKey 可以随机生成一个,用以后面的消息加密。加密方式可以按需设置。

服务器验证

点击提交时,微信平台会立刻发送一个 HTTP GET 请求到设定的服务 URL 上,服务器必须的原样返回请求中所带的 echostr 才能通过验证。因此,在提交前,要保证你的的服务 URL 可以正常工作。这里我在 Azure App Services 上部署了一个基于 ASP.NET Core 的 Web API 应用,针对微信的验证请求原样返回 echostr。

1
2
3
4
5
6
7
8
9
10
11
12
[HttpGet("")]
public string ReturnEchostr([FromQuery]string signature, [FromQuery]string nonce, [FromQuery]string timestamp, [FromQuery]string echostr)
{
if (WXHelper.IsMessageFromWX(signature, nonce, timestamp, WxToken))
{
return echostr;
}
else
{
return "Failed to authenticate the request";
}
}

1
2
3
4
5
6
7
8
9
10
11
//根据微信开发接入指南的规则来确认请求的来源
public static bool IsMessageFromWX(string signature, string nonce, string timestamp, string wxToken)
{
string[] tempArr = { nonce, timestamp, wxToken };
Array.Sort(tempArr);
string tempStr = string.Join("", tempArr);
var sha1 = SHA1.Create();
byte[] hashBytes = sha1.ComputeHash(Encoding.ASCII.GetBytes(tempStr));
string calculatedSignature = BitConverter.ToString(hashBytes).Replace("-", "").ToLower();
return calculatedSignature == signature;
}

验证 URL 有效性成功后即接入生效,成为开发者。

服务开发

消息的转发

上面我们提到,Azure Bot Service 没有官方的微信 Channel,因此我们需要通过程序把用户在微信公众号中输入的消息转发给 Bot。这里需要用到 Bot 的一个特殊 Channel, Directline Channel。 Directline Channel 支持开发者通过 HTTP GET 或者 WebSocket stream 来和 Bot 进行交互。所以,我们需要把服务器上收到的用户消息,通过 Directline 发送给 Bot;再通过 Directline 获取 Bot 的回复,并把结果返回给用户。
这里需要注意的是:微信公众号不支持主动消息(特定动作交互除外,具体见 这里),必须是客户先说话,服务器紧接着只能回复一条消息,而通过 Microsfot Bot Framework 开发的 Bot 则没有这个要求。因此,在设计 Bot 的对话逻辑的时候,要注意兼容微信公众号的这种形式。服务器回复的消息内容为一个 XML 格式的字符串,文本消息的 XML 格式如下:

1
<xml> <ToUserName>< ![CDATA[toUser] ]></ToUserName> <FromUserName>< ![CDATA[fromUser] ]></FromUserName> <CreateTime>12345678</CreateTime> <MsgType>< ![CDATA[text] ]></MsgType> <Content>< ![CDATA[你好] ]></Content> </xml>"

Bot 端的功能开发我希望在另外一篇博客中详细的解释,这里用一个样例 Bot 作为接入演示。

DirectLine 的配置

在 Azure Bot Service 上, 需要开启 DirectLine Channel 并获取对应的接入 Secret Key。

通过 DirectLine 接入 Bot

消息转发的功能同样在上面的。NET Core Web API 应用中实现。我们可以用Microsoft.Bot.Connector.DirectLine来帮我们简化开发工作,避免手动的构造 HTTP 请求。在 Directline 中,一个完整的简单对话流程一般如下:

  1. Start Conversation(获取一个conversation id
  2. Send an activity to the bot(向 DirectLine 的 endpoint 发送一个类型为messageActivity
  3. Receive activities from the bot(通过conversation id从 DirectLine 的 endpoint 获取 Bot 的回复)
  4. End a conversation(结束对话)

步骤 2 和 3 可以重复进行,不需要严格的一一对应。步骤 3 中,每一次的返回值除了有一个activity Set,还有一个watermark标识。再次向 Bot 请求回复时,可以带上之前获得的watermark,那么已经回复过的activity将不会出现在新的activity set中。作用类似 TCP 协议中的 ACK,通过 watermark 标识可以保证 Bot 回复的消息不会丢失。
一个需要注意是,针对不同微信用户发过来的消息,需要建立不同的对话。可以维护一个微信用户<-->服务器服务器<-->Bot两种对话之间的映射关系。样例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
// WX Platform Will Post the Message to the Endpoint 
[HttpPost("")]
public async Task<string> WxPost([FromQuery]string signature, [FromQuery]string nonce, [FromQuery]string timestamp)
{
// WX Message Validataion
if (WXHelper.IsMessageFromWX(signature, nonce, timestamp, WxToken))
{
using (var reader = new StreamReader(Request.Body))
{
var body = reader.ReadToEnd();
if (String.IsNullOrEmpty(body))
{
return "Failed to get message";
}
// Parse WX Message
WXMsg msg = WXHelper.ParseWXMsgFromBodyString(body);
string wxUserId = msg.FromUserName.Trim();
string responseXML = "";

// In This Demo, We Only Care About Text Message
if (msg.MsgType == WXMsgType.Text)
{
if (!conMap.activeConversations.ContainsKey(wxUserId))
{
// Create a Directline Clint
var createdCon = await botClient.Conversations.StartConversationAsync();
conMap.activeConversations.Add(wxUserId, new ConversationInfo(createdCon, ""));
}

// Create a Bot Message Activity
Activity userMessage = new Activity
{
From = new ChannelAccount(wxUserId),
Text = msg.Content,
Type = ActivityTypes.Message
};

// Post the message to Bot
var thisConverstaionID = conMap.activeConversations[wxUserId].Conversation.ConversationId;
await botClient.Conversations.PostActivityAsync(thisConverstaionID, userMessage);

// Get the Activity Set from Bot
var activitySet = await botClient.Conversations.GetActivitiesAsync(thisConverstaionID, conMap.activeConversations[wxUserId].Waltermark);
conMap.activeConversations[wxUserId].Waltermark = activitySet.Watermark;
var activities = from x in activitySet.Activities
where x.From.Id == botId
select x;

// Porcess the Activities
var returnString = "";
foreach (Activity activity in activities)
{
returnString += activity.Text + "||";
}
responseXML = WXHelper.ConstructWXTextMessage(msg, returnString);
}
return responseXML;
}
}
else
{
return "";
}
}

在公众号上测试效果:

至此,用户已经可以和公众号的 Bot 进行对话,Bot 也能正确识别和记录对话的状态。在后面的博客中,我将记录 Bot 端更多功能的实现。

Reference: