輕鬆揪團不求人!在 LINE 上打造屬於你的 Chatbot 揪團小幫手

認識開源框架 Bottender 內建的 session 及 state 管理

吳東曄 Wu, Dung-Ie
YOCTOL.AI
11 min readMar 26, 2018

--

注意!這篇文章是舊版的,新版文章請參考「輕鬆揪團不求人!在 LINE 上打造屬於你的 Chatbot 揪團小幫手 (Bottender v1)」。

LINE 在台灣的高滲透率讓許多人在上面建立群組和親友同事聯絡溝通,一定也有人會用來揪團訂購下午茶或是網購產品,這時候如果能有個 Chatbot 幫忙大家整理訂單肯定方便不少。

這時候,Bottender 中 Session 的設計以及對 LINE Group/Room 兩種多人對話模式的原生支援就很適合拿來開發這種對話狀態會變化的 Chatbot。這篇文章將會示範如何使用 Bottender 來打造一個可以簡單處理揪團購的 LINE Chatbot。

用 LINE Chatbot 幫忙整理訂單!

前置準備

首先我們需要建立一個 LINE@ 帳號取得 Access Token 以及 Channel Secret,細節在此不贅述,可參閱 Bottender 的 LINE 教學或是我們的上一篇教學文章「以 JavaScript 撰寫簡單的卡米狗」。

接下來使用 bottender init 互動式指令來創建一個新的 Bottender 專案,需要注意的地方有:

  1. 在上述互動式的準備過程中,要選擇 line 作為 platform,而 session 則按照需求自行選擇,此範例中使用 memory 作為示範。
  2. 必須把之前準備好的 ACCESS_TOKEN 以及 CHANNEL_SECRET 填入 .env

備註:由於 memory session 不具備持久化策略,伺服器若重新啟動 session 資料會被重置。

實作功能

以下步驟的完整 Repository 可以在這裡找到。

基本設定

index.js 中,為了方便使用 Bottender 提供的 state 管理,必須在初始化時提供 initial state,同時我們將 handler 另外拆出一個 module 方便管理。

// index.js 稍作修改const handler = require('./handler');// 初始的 state
bot.setInitialState({
開團中: false,
開團人: '',
訂單: [],
});
bot.onEvent(handler);

使用 Handler 類別定義指令

Bottender 內建有使用 Builder 設計模式的 Handler 類別方便組裝規則,並且可以多層次組合,符合這個案例中要按照對話狀態接受不同指令的需求,在 handler.js 中我們可以這樣做:

// handler.jsconst { LineHandler } = require('bottender');// 沒開團的狀態下,輸入「開團」可以開團
const 未開團handler = new LineHandler().onText('開團', 開團).build();
// 已開團的狀態下,有四種指令可以用
const 開團中handler = new LineHandler()
.onText('截止', 截止)
.onText('統計', 統計)
.onText('取消', 取消訂單)
.onText(/^我也?要(.*)/, 下訂單)
.build();
// 按照 state 決定現在的狀態要用哪個子 handler
module.exports = new LineHandler()
.on(context => !context.state.開團中, 未開團handler)
.on(context => context.state.開團中, 開團中handler)
.build();

其中的 開團截止統計取消訂單下訂單都是單一動作的 handler,之後分別完成後再用 require() 的方式引用進來使用。

可以注意到我們使用了 context.state.開團中 這個值來判斷開團狀態,在 Bottender 中如果是在 LINE 的聊天室或群組內,對話狀態是在整個群體內共享的,所以只要是在同一個群組或聊天室內,都可以得到一樣的開團與否狀態,並且從前面 index.js 設定初始狀態時可以發現,我們也將把群內的訂單以及開團人資訊存在 state 之中。

開團和截止

開團和截止相對簡單,開團指令要做的就是把 state 中的 開團中 設為 true 並且記錄開團人資訊;而截止可以直接把狀態重置回初始值就好,回話時從 session.user 中取得說話者的 displayName 即 LINE 上的顯示名稱:

// actions/開團.jsmodule.exports = async context => {
const { displayName } = context.session.user;
// 設定為開團 state
context.setState({
開團中: true,
開團人: context.session.user,
訂單: [],
});
await context.replyText(`${displayName} 開團囉! 大家快來點!`);
};

截止的指令中,我們可以做個檢查只讓開團的人能夠截止:

// actions/截止.jsmodule.exports = async context => {
const { userId, displayName } = context.session.user;
// 判斷說出截止指令的人是不是開團的人
if (context.state.開團人.userId === userId) {
// 把 state 重設
context.resetState();
await context.replyText('截止囉!');
} else {
await context.replyText(`${displayName} 不是你開的團,不讓你截止`);
}
};

下訂單和取消訂單

主揪能夠開團和截止之後,就要讓團員能夠下單選擇自己要的項目,由於我們在下單的指令上使用了 onText() 搭配正規表達式,Bottender 會將 match() 的結果傳入第二個參數當中,即是我們想要知道的訂單內容,我們可以把訂單內容以及訂購人 ID 使用 context.setState() 存入 state 中:

// actions/下訂單.js// Handler Builder 中 onText() 如果使用 Regular Expression 會把 match 的結果傳入第二個參數
module.exports = async (context, match) => {
const { userId, displayName } = context.session.user;
// 檢查訂單裡面有沒有這個人點過的東西
if (context.state.訂單.some(obj => obj.userId === userId)) {
await context.replyText(
`${displayName} 你已經點過了,可以輸入「取消」再點一次`
);
} else {
const order = match[1].trim();
// 把訂單塞進 state 中
context.setState({
...context.state,
訂單: context.state.訂單.concat({
name: displayName,
userId,
order,
}),
});
await context.sendText(`我知道 ${displayName} 你點的是 ${order}`);
}
};

如果要取消訂單,也是使用 context.setState() 來改變 state 中的訂單資訊:

// actions/取消訂單.jsmodule.exports = async context => {
const { userId, displayName } = context.session.user;
// 檢查訂單裡面有沒有這個人點過的東西
if (context.state.訂單.some(obj => obj.userId === userId)) {
// 取消就是把訂單裡面這個人的東西過濾掉
context.setState({
...context.state,
訂單: context.state.訂單.filter(order => order.userId !== userId),
});
await context.replyText(`${displayName} 幫你取消囉!`);
} else {
await context.replyText(
`${displayName} 你沒點過無法取消,輸入「我要ooo」來下訂單`
);
}
};

統計

在截止前,當然要能夠查看現在大家都點了什麼東西,所以需要一個統計指令。在統計指令當中我們會取出 state 裡的訂單然後重新調整結構方便排版,按照文字排序並將同樣的項目合併為結果中的同一行,輸出時一行一個項目比較好整理:

// actions/統計.jsmodule.exports = async context => {
const sortedOrders = context.state.訂單
// 按照文字排序
.sort((a, b) => a.order.localeCompare(b.order))
.reduce((prev, o) => {
const { name, order } = o;
// 檢查有沒有人點過一樣的東西
const match = Object.keys(prev).find(k => order === k);
if (match) {
// 有人的話就把名字接在 array 後面
return {
...prev,
[order]: prev[order].concat(name),
};
}
// 或者新開 array
return { ...prev, [order]: [name] };
}, {});
const orderNames = Object.keys(sortedOrders);// 稍微排版一下,一行一種物品
const result = orderNames
.map(
o =>
`${o} 有 ${sortedOrders[o].length} 份,是 ${sortedOrders[o].join(
', '
)} 點的`
)
.join('\n');
// 避免沒有訂單傳送空字串出現錯誤
await context.replyText(result || '沒有訂單QQ');
};

到這邊為止整個 Chatbot 的功能大致上完成了,要測試的話可以使用 ngroklocaltunnel 等服務建立一個 proxy 到開發環境、或是將 server 部署到線上服務,再到 LINE Developers 後台設定 Webhook 就可以進行使用。

趕快試試用 Chatbot 向親朋好友揪團吧!

後記

在實作 Chatbot 時,對於對話流程的掌握是很重要的一環,由於和 Chatbot 的互動不像使用網頁瀏覽器,幾乎各種後端語言都支援方便地使用 cookies 來管理 session,因此趁這個機會使用生活化的例子向大家介紹 Bottender 內建的 session 及 state 管理。

如果你使用 Bottender 做出了有趣的 Chatbot,別忘了到 Facebook 上的 Chatbot Developers Taiwan 社團跟大家分享!

最後順帶一提,這個範例是簡化自優拓內部使用的 Slack Chatbot 的功能,我們的下訂單指令使用的正規表達式並非範例中的 /^我也?要(.*)/ 而是更加複雜且功能更專精的

/(\S+)\s*((?:[全半少微無去大中小]|正常)[糖冰杯]|[熱溫]的?)\s*((?:[全半少微無去大中小]|正常)[糖冰杯]|[熱溫]的?)?\s*((?:[全半少微無去大中小]|正常)[糖冰杯]|[熱溫]的?)?/

有興趣的話也可以使用這個正規表達式看看能達成什麼效果喔!

參考資料

這個範例的完整程式碼可以在 bottender-order-example 找到。

跟 Bottender 或是 Chatbot 開發相關的討論除了上 GitHub 開 Issue 以外,習慣使用聊天室的人也可以到我們的 Discord Server 參與討論。

Bottender 的 文件網站GitHub Repository 有許多資源可以參考。

想看其他 Bottender 教學文章可以參考 以 JavaScript 撰寫簡單的卡米狗

YOCTOL.AI — AI 商業生態圈的開創者

前瞻的 AI 運算技術,為程式開發人員、企業管理者、行銷人員,打造最完備的商業智慧環境。

--

--