# 2021 梅竹黑客松 LINE 工作坊

# Messaging API 基礎知識

下圖是當使用者傳送訊息給官方帳號然後到收到回覆的時序圖:

以下列出的是 Messaging API 的其中幾個重要功能:

# 接收使用者傳送的訊息

可以取得使用者傳送的文字、貼圖、圖片、影片、定位、事件、日期時間、Beacon...等訊息。

底下會介紹一個筆者所開發的「Flex 開發人員工具」可以測試大部分的訊息。

這邊要特別注意的是,使用者傳送的圖片跟影片只會保留在 LINE 的伺服器中一小段時間,超過就沒辦法存取了。

詳細內容可以看官方文件:https://developers.line.biz/en/reference/messaging-api/#webhook-event-objects (opens new window)

# 回覆/推送訊息

可以回覆/推送多種不同類型的訊息給使用者:

  • 文字訊息 Text
  • 貼圖訊息 Sticker
  • 圖片訊息 Image
  • 影片訊息 Video
  • 語音訊息 Audio
  • 定位訊息 Location
  • Imagemap 訊息
  • Template 訊息
  • Flex 訊息

詳細內容可以看官方文件:https://developers.line.biz/en/docs/messaging-api/message-types/ (opens new window)

# Flex 訊息

這裡特地把 Flex 訊息拉出來講,就是因為 Flex 訊息彈性非常大,可以做出很多很漂亮的訊息畫面,我們在 Messaging API 跟等等會提到的 LIFF 裡面都能夠使用 Flex 訊息。

官方有提供一個好用的「Flex 訊息模擬器」 (opens new window)工具,另外筆者在開發 Flex 訊息時也很常用自己的「Flex 開發人員工具」。

# 圖文選單 Richmenu

圖文選單是一個高度客製化的選單,可以讓使用者快速瞭解怎麼使用官方帳號,但是只能在手機上看到圖文選單。你可以寫程式去新增、刪除、設定預設圖文選單,還能為特定使用者指定選單。

詳細內容可以看官方文件:https://developers.line.biz/en/docs/messaging-api/using-rich-menus/ (opens new window)

# LINE Beacon

你可以製作一個 LINE Beacon 的藍牙裝置,當使用者靠近 LINE Beacon 時,你就會收到相關事件,並且決定官方帳號要傳送什麼訊息給使用者。

詳細內容可以看官方文件:

# 「Flex 開發人員工具」介紹及 DEMO

這是由筆者所開發的工具機器人,支援這幾個重要功能:

  1. 直接用 JSON 印出收到的 Event
  2. 以文字傳送訊息就會直接回覆
  3. 直接顯示從 Flex Message Simulator 複製下來的 JSON

簡報網址(內有加入好友網址):https://hackmd.io/@taichunmin/chatbot-tw-202107 (opens new window)

# 到 CodeSandbox 建立專案

CodeSandbox (opens new window) 是一個可以線上開發 Node.js 專案的練習網站,支援各大 JS 前端與後端框架。我們也可以用這個服務來建立一個 LINE 的 Messaging API 後端 Webhook。

如果還沒註冊 CodeSandbox (opens new window) 請先前往註冊。註冊完成後,請開啟下方的連結建立本次工作坊的範本:

從程式碼範本建立 CodeSandbox

請開啟此網頁來建立本次工作坊的範本:https://githubbox.com/taichunmin/meichuhackathon2021 (opens new window)

開啟上方的網址後,請點一下左上方的「Fork」按鈕來把這個範本存到自己的帳號內:

這裡需要特別注意的地方是,如果 Fork 成功後,你看到的網址應該要跟圖片的不同:

接下來我們就可以來設定 Messaging API 囉!

# 建立 Messaging API Channel

為了讓我們可以寫程式從 Webhook 接收使用者傳送到官方帳號的訊息,你需要先建立一個 Messaging API Chennel。

Messaging API Chennel 建立完成之後,請前往 LINE Developer Console。

請點此開啟 LINE Develpers console

開啟後台之後,去該頻道的設定頁面,開啟 Messaging API 分頁:

在後台找到「Webhook settings」的部份,然後從 CodeSandbox 複製網址(每個人網址都不同) :

把剛剛複製的網址填到 Webhook URL 後,也要確認底下的「Use webhook」也是啟用的狀態:

在後台中找到「Channel access token (long-lived)」的部份,然後點選資料右側的複製按鈕。如果你的「Channel access token (long-lived)」中沒有 Access Token,你可以點選「Issue」建立一個。

然後回到 CodeSandbox 切換到左側的「Server Control Panel」側邊欄,然後找到 Secret Keys 的設定:

請把剛剛複製的 Channel access token (long-lived) 貼到 Value 的輸入框中,然後 Name 填上 LINEOA_ACCESS_TOKEN (一樣可以在 Sandbox 中複製):

然後就可以按下「Add Secret」儲存了。

在後台的「Basic Setting」分頁中找到「Channel secret」的資料,然後點選資料右側的複製按鈕:

然後回到 CodeSandbox,跟剛剛的步驟一樣,設定到 LINEOA_SECRET 的 Secret Keys 中:

設定完成以後,請在後台的「Messaging API」分頁找到剛剛設定「Webhook URL」的地方,點一下 Verify 按鈕:

若是設定成功,應該會顯示成功的訊息。

接下來打開自己的 LINE,掃描 QR Code 把這個 Messaging API 頻道加為好友。

然後輸入「test」確認 Webhook 設定成功。

# Messaging API 程式碼範本解說

記得自學基礎語法

這個工作坊不會教 express.js、javascript 跟 Node.js 的基礎語法,如果對這些語法還不太熟悉的朋友,建議可以去買書來學,或者是參考以下幾個線上的英文學習網站喔:

這個範本筆者是基於 Node.js 中最多人使用的 express 框架來開發的,筆者在 line/index.js 定義了處理 LINE Webhook 的程式:

// 在這裡的 middleware 是 @line/bot-sdk 所提供用來檢查資料有沒有被竄改的函式
router.post('/', middleware, async (req, res) => {
  try {
    // express 會把 HTTP POST 的資料放在 req.body 內
    // _.get 是 lodash 提供的好用函式,避免變數未定義導致錯誤
    const events = _.get(req, 'body.events', [])
    // 使用 Promise.all 非同步處理所有的 event
    // 同時每個 event 都宣告一個新的物件當作 ctx
    // 裡面會有 event 和來自 express 框架的 req 可以使用
    // 等等底下會解釋 ctx 的用途
    await Promise.all(_.map(events, (event) => eventHandler({ event, req })))
    // 最後需要回傳資料來結束 HTTP POST
    res.json({})
  } catch (err) {
    debug(err)
    res.status(err.status || 500).json({ message: err.message })
  }
})

再來我們把目光放到 eventHandler 這個函式,這個函式就是真正處理每個 event 的函式,在此筆者參考另一個在 Node.js 中很有名的框架 Koa.js,這個框架有一個很方便使用的 middlewareCompose 的工具函式,可以把多個函式包成一個單獨的函式,然後透過一個特別的寫法來決定要不要繼續往下執行下一個函式。在此使用 line/echo-text.js 做說明:

// 所有的 middleware 函式都必須是兩個參數
// 通常第一個參數取名為 ctx,是 context 的縮寫
// context 中文翻作「上下文」或「前後文」
// 裡面會存放處理這個 event 可能會需要用到的資料或函式
// 以免污染別的 event
// 當然你也可以多放入其他資料或函式給下一個 middleware 函式使用
// 通常第二個參數取名為 next,它會是一個函式
// 如果呼叫這個 next 代表你希望繼續執行下一個 middleware 函式
module.exports = async (ctx, next) => {
  // 從 ctx 中取出 event
  const { event } = ctx
  // 如果 event 不是文字訊息,就呼叫下一個 middleware 函式處理
  // 並且下一個 middleware 函式處理結束後
  // 直接 return 結束當前的 middleware 函式
  if (event.message?.type !== 'text') return await next()
  // 會執行到這裡,代表這是文字訊息
  // 所以就直接把 event 中的文字訊息原樣回傳給使用者
  await event.replyMessage({ type: 'text', text: event.message?.text })
}

當你寫好所有的 middleware 函式後,就可以決定它們的順序,然後把多個 middleware 函式包成一個,讓我們把焦點看到檔案 line/index.jseventHandler 函式:

// middlewareCompose 的這個工具函式
// 可以把多個 middleware 函式包成一個
// 然後真正呼叫函式時,就會由第一個 middleware 函式開始依序執行
// 並且決定在什麼情況下執行下一個 middleware 函式
const eventHandler = middlewareCompose([
  // 執行順序 1,這個 middleware 函式在佈置處理 event 所需的場地 (ctx)
  require('./event-init'),
  // 執行順序 2,這個 middleware 函式是等等 LIFF 要用的工具指令
  require('./liff-url'),
  // 執行順序 3,一個應聲蟲機器人,但是只能處理文字訊息
  require('./echo-text'),
])

在這次工作坊中,你在寫 Webhook 的處理程式時,幾乎就只需要新增更多的 middleware 函式,然後妥善決定好每個 middleware 的順序即可,所以我們再來多看一個 middleware 函式 line/liff-url.js 吧:

const { liffUrl } = require('../libs/helper')

module.exports = async (ctx, next) => {
  // 從 ctx 中取出 event
  const { event } = ctx
  // 取得文字訊息,如果沒有這個資料,就會拿到空字串
  const text = event.message?.text || ''
  // 使用正規表示法判斷文字訊息是不是我們指定的格式
  // 格式是: /liff [size] [filename]
  const match = text.match(/^\/liff (full|tall|compact) ([A-Za-z0-9_-]+)$/)
  // 如果格式不正確,就交給下一個 middleware 處理
  // 並且下一個 middleware 函式處理結束後
  // 直接 return 結束當前的 middleware 函式
  if (!match) return await next()
  // 如果格式正確,我們就回傳指定大小的 LIFF 網址
  await event.replyMessage({ type: 'text', text: liffUrl(match[1], match[2]) })
}

接下來我們來看看檔案 line/event-init.js 裡面,筆者準備了什麼資料和函式給你處理 event 時可以使用吧:

const _ = require('lodash')
const { line } = require('../libs/line')
const debug = require('../libs/debug')(__filename)

module.exports = async (ctx, next) => {
  // 從 ctx 中取出 event
  const { event } = ctx

  // 把 @line/bot-sdk 放到 ctx.line 以備不時之需
  ctx.line = line

  // 在 event 中宣告一個 replyMessage 函式讓你可以方便的回傳訊息
  event.replyMessage = async messages => {
    // 先把 event.replyToken 存到另一個變數中備用
    const replyToken = event?.replyToken
    // 如果沒有 replyToken 可以用,就不回傳訊息
    // 可能的原因有幾個
    // 1. 這個訊息本身就沒有 replyToken (例如 unfollow 訊息)
    // 2. 粗心寫錯程式 (呼叫了兩次 event.replyMessage 函式)
    if (!replyToken) return
    // 馬上把 event.replyToken 刪除
    // 避免粗心寫錯程式,呼叫了兩次 event.replyMessage 函式
    // 因為每個 replyToken 都只能用一次
    delete event.replyToken
    // 使用 replyToken 呼叫 line.replyMessage 回傳訊息
    await line.replyMessage(replyToken, messages)
  }

  // 由於我們的 middleware 函式可能會出現錯誤
  // 為了不要其中一個 event 出錯就導致其他 event 也出錯
  // 我們需要用 try catch 來把這個 event 的所有的錯誤都抓下來
  // 並且嘗試使用 replyToken 回傳錯誤訊息
  // 方便我們除錯
  try {
    // 一律執行接下來的 middleware 函式
    await next()
  } catch (err) {
    // 由於 @line/bot-sdk 和 axios 的錯誤
    // 都需要額外處理才能看到有用的錯誤訊息
    // 所以這邊在判斷是否有這兩個套件的特殊錯誤
    err.message = err.originalError?.response?.data?.message ?? err.response?.data?.message ?? err.message
    // 在紀錄錯誤的時候,同時也把 event 的內容記錄下來
    _.set(err, 'data.event', event)
    // 紀錄發生的錯誤
    debug('err = %j', err)
    // 嘗試回傳錯誤訊息
    await event.replyMessage({ type: 'text', text: err.message })
  }
}

有沒有稍微了解該怎麼寫新的 middleware 函式了呢?接下來請各位嘗試看看實做一個 middleware 函式,當使用者傳送了機器人不認識的訊息(如貼圖)時,告訴使用者一些資訊。

# 程式練習 1

這個練習請自由發揮,你可以讓機器人回覆很正常的內容(如:對不起,程式目前無法處理這個訊息),又或者是教使用者該怎麼使用你的機器人,甚至你還可以給使用者一個隨機笑話,降低使用者的挫敗感喔。

這是筆者提供的範例解答,別急著打開喔!

新增一個檔案 line/unknown.js,檔案內容如下:

module.exports = async (ctx, next) => {
  const { event } = ctx
  await event.replyMessage({ type: 'text', text: '不好意思,我還沒辦法理解你傳送的訊息。' })
}

然後你會需要修改 line/index.js 把這個 middleware 函式加進去:

const eventHandler = middlewareCompose([
  require('./event-init'),
  require('./liff-url'),
  require('./echo-text'),
  require('./unknown'), // 加在最後面
])

接下來,請按下「Restart Server」然後傳送一個貼圖給機器人,測試看看有沒有正確回傳這個文字訊息喔!

# 程式練習 2

嘗試在收到 Webhook 的 Follow 歡迎訊息時,回覆一個客製化訊息給使用者,並把使用者的 LINE 暱稱寫到文字中吧!

這是你可能會需要的參考連結:

這是筆者提供的範例解答,別急著打開喔!

新增一個檔案 line/follow.js,檔案內容如下:

module.exports = async (ctx, next) => {
  const { event, line } = ctx
  if (event.type !== 'follow') return await next() // 不是 follow 訊息
  const userId = event.source?.userId
  if (!userId) return await next() // 沒有 userId
  const profile = await line.getProfile(userId)
  const displayName = profile?.displayName || '無名氏'
  await event.replyMessage({ type: 'text', text: `${displayName} 你好,這是由戴均民所開發的聊天機器人,請好好愛惜它喔!` })
}

然後你會需要修改檔案 line/index.js,把這個 middleware 函式加進去:

const eventHandler = middlewareCompose([
  require('./event-init'),
  require('./follow'), // 加在 event-init 後面
  require('./liff-url'),
  require('./echo-text'),
  require('./unknown'),
])

然後你就可以先把官方帳號「封鎖」後再「解除封鎖」,應該就能夠收到我們新增的歡迎訊息囉!

順帶一提,如果想要把內建的歡迎訊息關閉,可以到 LINE Developer Console (opens new window) 中的「Messaging API」分頁,找到「Greeting messages」並改成 Disabled 就行囉。

# LIFF 基礎知識

LINE Front-end Framework (簡稱 LIFF),指的是使用 HTML 相關的技術配合 LIFF SDK 來製作網頁給使用者操作,你可以用來增加官方帳號的使用者體驗,你也可以只單純使用 LIFF 來提供使用者服務。

舉例來說,理財動物園 (opens new window)使用了 LIFF 來製作一個計算機方便使用者計算金額:

筆者製作的「LINE 數位版名片」 (opens new window)就是一個純 LIFF 的專案。

LIFF 能夠在 Android、iOS 跟外部瀏覽器(如:電腦)中執行。如果是在手機上的 LINE 執行 LIFF 時,可以指定 Full (100%)、Tall (75%)、Compact (50%) 三種不同的大小。

以下列出的是 LIFF 所提供的幾個重要功能:

# 確認是不是 Messaging API Channel 的好友 liff.getFriendship()

如果你有透過「Linked OA」設定幫你的 LINE Login 指定過 Messaging API,你就可以呼叫這個 API 來確認 LIFF 的使用者是不是也是 Messaging API 的好友。

這個功能也可以被用來追蹤使用者加入 Messaging API 的管道,方便你分析不同廣告管道之間的成本與成效。

# 傳送訊息到聊天室 liff.sendMessages()

如果使用者是從 LINE 的 APP 開啟 LIFF 網頁,你可以把訊息傳送到聊天室內,底下會有這個 API 的範例。

# 分享訊息 liff.shareTargetPicker()

可以分享某些訊息給使用者所選擇的好友,也支援 Flex 訊息。

筆者用這個 API 製作了「LINE 數位版名片」 (opens new window),這個專案可以簡單的用 Flex 訊息建立名片或傳單,並分享給好友。這是我最近所寫的教學文章:https://taichunmin.idv.tw/blog/2021-07-09-line-card-create-carousel-1.html (opens new window)

# 掃描 QRCode 碼

讓你可以在 LIFF 中開啟相機,然後掃描 QRCode 碼。如果想要嘗試看看,可以看我寫的文章:https://taichunmin.idv.tw/blog/2021-09-30-liff-scan-code-v2.html (opens new window)

# 建立 LINE Login 及設定 LIFF

為了要開發 LIFF,現在就讓我們來建立一個 LINE Login 吧:

WARNING

在建立 LINE Login 時有一個需要特別注意的地方,就是 LINE Login 和 Messaging API 頻道必須要建立在同一個 Provider 內,不然你會沒辦法抓到相同的 userId。

建立完成以後,我們會需要幫 Full、Tall、Compact 三個不同大小的 LIFF 各建立一個 LIFF 並取得 LIFF ID,在此我們以 Full 做示範,請切換到「LIFF」分頁,然後點一下「Add」按鈕:

在「LIFF app name」填上自己喜歡的名字(使用者可以看到這個名字),然後「Size」選擇「Full」:

然後回到 CodeSandbox 的畫面,複製 Full 大小的 Endpoint URL:

然後回到後台貼到「Endpoint URL」欄位中:

接下來的幾個選項就照下圖的設定方式,注意「Scope」中的三個權限都要勾選:

建立完成以後,就可以在列表中找到隨機產生的 LIFF ID,我們需要點選右邊的複製按鈕複製下來:

接下來回到 CodeSandbox 並且設定指定的環境變數:

然後切換到「Server Control Panel」並且新增這個「LIFFID_FULL」的設定值:

依此類推,請把「LIFFID_TALL」跟「LIFFID_COMPACT」都設定完成,然後按下「Restart Server」按鈕。

為了測試有沒有設定成功,請在官方帳號輸入「/liff full profile」然後打開機器人回覆的 LIFF 網頁,如果有設定成功,你應該就能夠從 LIFF 中看到自己的個人資料:

# LIFF 程式碼範本解說

記得自學基礎語法

這個工作坊不會教 HTML、javascript、Bootstrap、express.js、Vue.js 跟 Pug 的基礎語法,如果對這些語法還不太熟悉的朋友,建議可以去買書來學,或者是參考以下幾個線上的英文學習網站喔:

這個範本筆者是基於 Node.js 中最多人使用的 express 框架來開發的,express.js 預設使用 pug.js 這個樣版引擎來產生 HTML,筆者在檔案 views/liff/profile.pug 寫了一個 LIFF 網頁,這個網頁使用了 Vue.js 作為前端的框架,讓網頁在啟動後取得使用者的個人資料並顯示在網頁上。

LIFF SDK 在使用前都會需要進行初始化,為了要取得使用者的個人資料,所以我們也會需要確認使用者是已經登入的狀態:

// 這個 window.liffLogin 是用來確保 LIFF 初始化完成
// 並且確保使用者也是已經登入的狀態
// 透過宣告一個 async 函式並且馬上執行
// 我們就可以把執行這個函式回傳的 Promise
// 儲存在這個變數裡面以便我們後續的使用
window.liffLogin = (async () => {
  // LIFF SDK 使用前都需要初始化
  // 因為安全性考量,你必須在初始化時指定 LIFF ID 參數
  // 避免 LIFF 網頁被惡意人士攻擊
  // 在此筆者直接寫程式抓取環境變數並自動帶入
  // 所以如果要做新的 LIFF 網頁就依樣畫葫蘆即可
  await liff.init({ liffId: '#{liffid}' })
  // 判斷使用者是否已經登入
  // 如果是從 LINE APP 中開啟的使用者預設就會是登入狀態
  // 如果是外部瀏覽器就會需要先登入
  // 由於我們想要取得使用者的資料
  // 所以如果使用者沒有登入
  // 就要要求使用者登入
  if (!liff.isLoggedIn()) {
    // 登入並且在成功後跳轉回本頁
    liff.login({ redirectUri: location.href })
    // 這是為了讓這個 async 函式永遠不要結束
    // 因為我們正在要求使用者登入
    // 如果沒登入我們也沒辦法拿到個人資料
    // 所以接下來的程式沒有執行的必要
    await new Promise(resolve => {})
  }
})() // 馬上執行這個函式,並把回傳的 Promise 存下來

接下來,我們把目光看到 mounted 這個函式,在 Vue.js 中,呼叫 mounted 時就代表 Vue.js 已經初始化完成,通常筆者會在這個函式抓取畫面顯示所需的資料:

// 這個是在 ES6 中宣告一個物件的成員函式的語法
// 所以不能加上 function
async mounted () {
  // 當寫可能發生錯誤的程式碼時
  // 就需要使用 try catch 來妥善處理錯誤
  try {
    // 為了要取得使用者的個人資料
    // 所以我們需要等這個 Promise 執行結束
    // 如果成功結束
    // 就代表 LIFF 已經初始化完成
    // 而且也已經確保使用者是登入的狀態
    await window.liffLogin
    // 呼叫 liff.getProfile() API
    // 可以取得使用的個人資料
    // Vue.js 的一個很方便的特性就是
    // 畫面上的內容會跟物件的屬性做綁定
    // 所以我們只需要把個人資料
    // 存到這個變數內即可
    this.profile = await liff.getProfile()
  } catch (err) {
    // 發生錯誤時把錯誤訊息紀錄到 console
    console.log(err)
  }
},

接下來讓我們看到畫面的部份:

block content
  //- 後面的 v-cloak 屬性是為了讓畫面一開始先不顯示
  //- 等 Vue.js 處理完成後才顯示畫面
  #app.py-3(v-cloak)
    .container.text-monospace
      h1 getProfile
      //- 把 profile 的內容先轉成 JSON 字串
      //- 然後再顯示到畫面上面
      pre #[code {{ JSON.stringify(profile, null, 2) }}]

# 程式練習 3

接下來,我們來幫這個 LIFF 網頁加上傳送訊息的功能吧!請用使用者的暱稱來寫成一段文字(自由發揮),然後傳送到聊天室內吧!

這是你可能會需要的參考連結:

這是筆者提供的範例解答,別急著打開喔!

我們要先來修改 HTML 的部分,幫網頁加上一顆按鈕:

block content
  #app.py-3(v-cloak)
    .container.text-monospace
      h1 getProfile
      pre #[code {{ JSON.stringify(profile, null, 2) }}]
      //- 只有新增底下這一行程式,其他都不用動
      button.btn.btn-primary.btn-block(type="button", @click="btnSend") 送出訊息

然後我們要幫底下 Vue.js 的部分新增所需的程式,在 Pug 檔案內的程式碼縮進 (Indent) 是很重要的喔:

window.vm = new Vue({
  el: '#app',
  data: {
    profile: null,
  },
  async mounted () {
    // 此處省略...
  },
  methods: {
    canSendMessages () {
      // 檢查是否是從 LINE APP 中開啟網頁
      // 因為外部瀏覽器沒辦法傳送訊息到聊天室內
      if (!liff.isInClient()) return false
      // 檢查聊天室的類型
      // 如果沒有正確的聊天室類型
      // 也一樣不能傳送訊息到聊天室內
      const contextType = _.get(liff.getContext(), 'type')
      if (!_.includes(['utou', 'room', 'group', 'square_chat'], contextType)) return false
      return true
    },
    async btnSend () {
      try {
        if (!this.canSendMessages()) throw new Error('請在聊天視窗內重新開啟網頁')
        await liff.sendMessages([{ type: 'text', text: `${this.profile?.displayName} 覺得很棒!` }])
        await Swal.fire({ icon: 'success', title: '傳送成功' })
        liff.closeWindow()
      } catch (err) {
        console.log(err)
        await Swal.fire({ icon: 'error', title: '傳送失敗', text: err.message })
      }
    },
  },
})

# 原始碼與相關連結

TIP

本文範例程式的原始碼授權為 MIT License,若您有任何疑惑,你可以透過 Facebook (opens new window) 與我聯繫。