簡明 Python LINE Bot & LIFF JS SDK 入門教學


Posted by KD Chang on 2020-01-12

前言

隨著 Mobile App 安裝成本上升以及 LINE ChatBot 生態圈越來越深化,有越來越多的服務搭載著 LINE Bot 平台,希望能更容易觸及到更多的使用者。本文透過一個簡易的匯率管家 Chatbot 帶領讀者透過 LINE Messaging API 和 LINE LIFF JS SDK 完成一個簡單的查詢匯率聊天機器人。

開發上使用到的技術工具:

  1. LINE Messaging API SDK for Python
  2. LINE Front-end Framework
  3. 全球即時匯率API
  4. Python Flask
  5. Python requests
  6. Flask CORS
  7. ngrok

LINE Bot 專案初體驗

這次開發 LINE Bot 專案,我們使用 Python 和 Flask 為主要開發工具,LIFF 部分則是使用 JavaScript,在本機端的 Server export 使用 ngrok。

專案功能需求為:

  1. 提供使用者選單頁面可以選擇查詢匯率回傳目前美金對台幣匯率
  2. 提供使用者選單頁面可以選擇後跳出視窗讓使用者可以輸入台幣數量後回傳可兌換多少美金

首先,我們先來認識一下 Line Bot 系統架構:

  1. Line Client 就是使用者使用的終端介面,通常是 Line Channel
  2. Line Server 是接收 Line Client 的互動請求
  3. Webhook Server 是我們商業邏輯、回傳 Component 規劃以及 Line Bot SDK 運作的地方

首先,我們先到後台設定 Provider 和 Channel(可以想成一個 Chatbot 就是一個 Channel):

Provider 下面可以設定 Messaging API Channel:

設計選單列表

我們的專案作品成果希望如下:

所以我們需要設定選單列表,讓使用者點選後可以發出 @查詢匯率 文字和 開啟 LIFF 頁面輸入台幣金額換算美金。

建立選單列表:

我們使用兩欄式,可以自行上傳圖片或是利用線上編輯工具編輯:

點選按鈕左邊為送出文字(@查詢匯率),右邊為啟動連結(在 Messsaging API 的 LIFF Tab 創建後會的到連結,可以填入此處)

使用 Python LINE Bot SDK 串接 Webhook Server

接著,我們先安裝 LINE Messaging API SDK for Python

$ pip install line-bot-sdk
$ pip install flask
$ pip install requests
$ pip install flask_cors

參考範例(一個會根據使用者輸入回傳一樣的文字的 echo 聊天機器人):

from flask import Flask, request, abort

from linebot import (
    LineBotApi, WebhookHandler
)
from linebot.exceptions import (
    InvalidSignatureError
)
from linebot.models import (
    MessageEvent, TextMessage, TextSendMessage,
)

app = Flask(__name__)

# get channel_secret and channel_access_token from your environment variable
channel_secret = os.getenv('LINE_CHANNEL_SECRET', None)
channel_access_token = os.getenv('LINE_CHANNEL_ACCESS_TOKEN', None)
if channel_secret is None or channel_access_token is None:
    print('Specify LINE_CHANNEL_SECRET and LINE_CHANNEL_ACCESS_TOKEN as environment variables.')
    sys.exit(1)

line_bot_api = LineBotApi(channel_access_token)
handler = WebhookHandler(channel_secret)

# 此為 Webhook callback endpoint
@app.route("/callback", methods=['POST'])
def callback():
    # get X-Line-Signature header value
    signature = request.headers['X-Line-Signature']

    # get request body as text
    body = request.get_data(as_text=True)
    app.logger.info("Request body: " + body)

    # handle webhook body(負責)
    try:
        handler.handle(body, signature)
    except InvalidSignatureError:
        print("Invalid signature. Please check your channel access token/channel secret.")
        abort(400)

    return 'OK'

# decorator 負責判斷 event 為 MessageEvent 實例,event.message 為 TextMessage 實例。所以此為處理 TextMessage 的 handler
@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
    # 決定要回傳什麼 Component 到 Channel
    line_bot_api.reply_message(
        event.reply_token,
        TextSendMessage(text=event.message.text))


if __name__ == '__main__':
    app.run()

在 Channel 管理後台取得 LINE_CHANNEL_SECRETLINE_CHANNEL_ACCESS_TOKEN(記得好好保管,不要上傳到網路上),並 export 到環境變數中($ export LINE_CHANNEL_SECRET=XXXXX):

回到後台啟動 Webhook 並填入 Webhook Server Endpoint Url:

為了能讓 LINE Server 可以呼叫到 Local Webhook Server Endpoint 所以使用 ngrok,真正上線可以使用使用 Cloud Server 進行部屬。

此時透過掃 QR Code 應該就能看到範例程式的 echo 聊天機器人。

我們希望當點選選單列表的查詢匯率時會回傳美金對台幣匯率,所以加上:

if input_text == '@查詢匯率':

當使用者輸入為 @查詢匯率,則發出請求到 全球即時匯率API 取得匯率資訊並整理後回傳到 LINE Server

參考完整程式碼:

import os, sys

from flask import Flask, request, abort, jsonify
import requests


from linebot import (
    LineBotApi, WebhookHandler
)
from linebot.exceptions import (
    InvalidSignatureError
)
from linebot.models import (
    MessageEvent, TextMessage, TextSendMessage,
)

app = Flask(__name__)

# get channel_secret and channel_access_token from your environment variable
channel_secret = os.getenv('LINE_CHANNEL_SECRET', None)
channel_access_token = os.getenv('LINE_CHANNEL_ACCESS_TOKEN', None)
if channel_secret is None or channel_access_token is None:
    print('Specify LINE_CHANNEL_SECRET and LINE_CHANNEL_ACCESS_TOKEN as environment variables.')
    sys.exit(1)

line_bot_api = LineBotApi(channel_access_token)
handler = WebhookHandler(channel_secret)


@app.route('/callback', methods=['POST'])
def callback():
    # get X-Line-Signature header value
    signature = request.headers['X-Line-Signature']

    # get request body as text
    body = request.get_data(as_text=True)
    app.logger.info('Request body: ' + body)

    # handle webhook body
    try:
        handler.handle(body, signature)
    except InvalidSignatureError:
        print('Invalid signature. Please check your channel access token/channel secret.')
        abort(400)

    return 'OK'

@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
    input_text = event.message.text

    if input_text == '@查詢匯率':
        resp = requests.get('https://tw.rter.info/capi.php')
        currency_data = resp.json()
        usd_to_twd = currency_data['USDTWD']['Exrate']

        line_bot_api.reply_message(
            event.reply_token,
            TextSendMessage(text=f'美元 USD 對台幣 TWD:1:{usd_to_twd}'))


if __name__ == '__main__':
    app.run()

使用 LINE LIFF JS SDK

LIFF 全名為 LINE Front-end Framework,根據官方的解釋,其提供了開發者可以使用 Web 前端技術搭配 LINE LIFF JS SDK 來進行和 Bot 的整合。換言之,我們可以在 Bot 中啟動一個嵌入的 Web App 和 LINE Bot 進行互動(取得登入訊息或是將網頁表單內容發送到聊天頻道)

首先先到 Channel 後台新增 LIFF,在 Messsaging API 的 LIFF Tab 創建後會得到連結。複製該連結到選單頁面的匯率換算按鈕。

我們希望當使用者點選匯率換算時可以跳出視窗讓使用者輸入台幣數量,送出後回傳結果到 Channel。

計算匯率 API 由於跨域請求的關係也需要注意 CORS 問題:

from flask_cors import CORS
from flask import Flask, request, abort, jsonify

app = Flask(__name__)
CORS(app)

@app.route('/currency_exchange', methods=['POST'])
def handle_currency_exchange():
    data = request.get_json()
    twd_data = data.get('twd')
    resp = requests.get('https://tw.rter.info/capi.php')
    currency_data = resp.json()
    usd_to_twd = currency_data['USDTWD']['Exrate']
    result = twd_data / usd_to_twd

    return jsonify({'result': result})

根據 LINE Front-end Framework 文件,我們需要引入 https://static.line-scdn.net/liff/edge/2.1/sdk.js SDK 到我們的 HTML 中。

index.html 是需要額外 host 的一個 Web App。使用 ngrok 和 $ python -m http.server 9999 來啟動另外一台 host 靜態網頁 Web App 本地伺服器。,在 LIFF 後台填入該 Web App 對應網址。

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Currency Exchange</title>
</head>

<body>
    <form action="">
        <label for="">請輸入台幣數量</label>
        <input id="twd-input" name="twd-input" type="text">
        <button id="submit-btn">換成美金</button>
    </form>
    <hr>
    <script src="https://static.line-scdn.net/liff/edge/2.1/sdk.js"></script>
</body>

</html>

LIFF_ID 為後台中去除 line://app/ 後的字串,用來初始化 SDK 並使用 sendMessages 送訊息到 Channel 中:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Currency Exchange</title>
</head>

<body>
    <form action="">
        <label for="">請輸入台幣數量</label>
        <input id="twd-input" name="twd-input" type="text">
        <button id="submit-btn">換成美金</button>
    </form>
    <hr>
    <script src="https://static.line-scdn.net/liff/edge/2.1/sdk.js"></script>
    <script>
        function initializeLiff(myLiffId) {
            liff
                .init({
                    liffId: myLiffId
                })
                .then(() => {
                    // start to use LIFF's api
                    initializeApp();
                })
                .catch((err) => {
                });
        }

        initializeLiff('LIFF_ID');

        function postData(url, data) {
            return fetch(url, {
                body: JSON.stringify(data),
                cache: 'no-cache',
                headers: {
                    'user-agent': 'Mozilla/4.0 MDN Example',
                    'content-type': 'application/json'
                },
                method: 'POST',
                mode: 'cors',
            })
                .then(response => response.json()) // 輸出成 json
        }


        document.getElementById('submit-btn').addEventListener('click', function () {
            if (!liff.isInClient()) {
                sendAlertIfNotInClient();
            } else {
                const twdInput = parseInt(document.getElementById('twd-input').value);
                postData('https://endipoint_url/currency_exchange', { twd: twdInput })
                    .then(data => {
                        liff.sendMessages([{
                            'type': 'text',
                            'text': "你有這麼多美金:" + data['result']
                        }]).then(function () {
                            window.alert('Message sent');
                        }).catch(function (error) {
                            window.alert('Error sending message: ' + error);
                        });
                        liff.closeWindow();
                    }) // JSON from `response.json()` call
                    .catch(error => alert(error))
            }
        });
    </script>
</body>

</html>

完整程式碼

app.py

import os, sys

from flask import Flask, request, abort, jsonify
import requests
from flask_cors import CORS


from linebot import (
    LineBotApi, WebhookHandler
)
from linebot.exceptions import (
    InvalidSignatureError
)
from linebot.models import (
    MessageEvent, TextMessage, TextSendMessage,
)

app = Flask(__name__)
CORS(app)

# get channel_secret and channel_access_token from your environment variable
channel_secret = os.getenv('LINE_CHANNEL_SECRET', None)
channel_access_token = os.getenv('LINE_CHANNEL_ACCESS_TOKEN', None)
if channel_secret is None or channel_access_token is None:
    print('Specify LINE_CHANNEL_SECRET and LINE_CHANNEL_ACCESS_TOKEN as environment variables.')
    sys.exit(1)

line_bot_api = LineBotApi(channel_access_token)
handler = WebhookHandler(channel_secret)


@app.route('/callback', methods=['POST'])
def callback():
    # get X-Line-Signature header value
    signature = request.headers['X-Line-Signature']

    # get request body as text
    body = request.get_data(as_text=True)
    app.logger.info('Request body: ' + body)

    # handle webhook body
    try:
        handler.handle(body, signature)
    except InvalidSignatureError:
        print('Invalid signature. Please check your channel access token/channel secret.')
        abort(400)

    return 'OK'


@app.route('/currency_exchange', methods=['POST'])
def handle_currency_exchange():
    data = request.get_json()
    twd_data = data.get('twd')
    resp = requests.get('https://tw.rter.info/capi.php')
    currency_data = resp.json()
    usd_to_twd = currency_data['USDTWD']['Exrate']
    result = twd_data / usd_to_twd

    return jsonify({'result': result})


@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
    input_text = event.message.text

    if input_text == '@查詢匯率':
        resp = requests.get('https://tw.rter.info/capi.php')
        currency_data = resp.json()
        usd_to_twd = currency_data['USDTWD']['Exrate']

        line_bot_api.reply_message(
            event.reply_token,
            TextSendMessage(text=f'美元 USD 對台幣 TWD:1:{usd_to_twd}'))


if __name__ == '__main__':
    app.run()

index.html:

<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <meta http-equiv="X-UA-Compatible" content="ie=edge">
    <title>Currency Exchange</title>
</head>

<body>
    <form action="">
        <label for="">請輸入台幣數量</label>
        <input id="twd-input" name="twd-input" type="text">
        <button id="submit-btn">換成美金</button>
    </form>
    <hr>
    <script src="https://static.line-scdn.net/liff/edge/2.1/sdk.js"></script>
    <script>
        function initializeLiff(myLiffId) {
            liff
                .init({
                    liffId: myLiffId
                })
                .then(() => {
                    // start to use LIFF's api
                    initializeApp();
                })
                .catch((err) => {
                });
        }

        initializeLiff('LIFF_ID');

        function postData(url, data) {
            return fetch(url, {
                body: JSON.stringify(data),
                cache: 'no-cache',
                headers: {
                    'user-agent': 'Mozilla/4.0 MDN Example',
                    'content-type': 'application/json'
                },
                method: 'POST',
                mode: 'cors',
            })
                .then(response => response.json()) // 輸出成 json
        }


        document.getElementById('submit-btn').addEventListener('click', function () {
            if (!liff.isInClient()) {
                sendAlertIfNotInClient();
            } else {
                const twdInput = parseInt(document.getElementById('twd-input').value);
                postData('https://endipoint_url/currency_exchange', { twd: twdInput })
                    .then(data => {
                        alert('1', data);
                        alert('1');
                        liff.sendMessages([{
                            'type': 'text',
                            'text': "你有這麼多美金:" + data['result']
                        }]).then(function () {
                            window.alert('Message sent');
                        }).catch(function (error) {
                            window.alert('Error sending message: ' + error);
                        });
                        liff.closeWindow();
                    }) // JSON from `response.json()` call
                    .catch(error => alert(error))
            }
        });
    </script>
</body>

</html>

總結

以上透過一個簡易的匯率管家 Chatbot 帶領讀者透過 LINE Messaging API 和 LINE LIFF JS SDK 完成一個簡單的查詢匯率聊天機器人。相信大家可以透過自己的創意巧思設計出有趣的應用程式。

參考文件

  1. LINE Messaging API SDK for Python
  2. LINE Front-end Framework
  3. 全球即時匯率API

#Messaging API Channel #LINE Chatbot #Chatbot #Bot #LINE SDK #LIFF









Related Posts

Web Storage1: HTTP, Session & Cookie

Web Storage1: HTTP, Session & Cookie

Day 120

Day 120

The introduction and difference between class component and function component in React

The introduction and difference between class component and function component in React




Newsletter




Comments