我怎麼開發串接網站通知的 LINE Bot?聊天機器人實作過程分享

在今年下半年,筆者很幸運的接到一個有趣的 LINE Bot 開發委託,有別於高中時 side project 總是從 0 開始寫 code,這次是基於委託方已經建構起來的服務跟程式去拓展,而撰寫本文前幾天我們很順利得上線了第一個版本。

筆者想趁記憶猶新時撰寫一下復盤筆記,分享我這次是怎麼串接網站通知到 LINE 機器人上,包含前期的構思設計、實作過程的作法,以及事後所學的整理。

如果你也有在做程式的接案工作,抑或是剛學完 LINE Bot 開發、想了解接下來的學習 Roadmap,個人都希望能透過這篇文章給你一些幫助或參考。


前情提要

網路上有不少 LINE 機器人的程式開發教學,主題是教你怎麼寫 LINE Bot,個人先前也寫過一篇怎麼自動化記錄群組 +1 名單

但這次筆者想分享些不一樣的,本文會著重解析我在學會 LINE Chabot 之後,是怎麼用在現實場景跟專案需求中,以及跟 Web、Database 做串接,我想這可能是很多人會想知道的事情( Maybe? )

也因為這次分享的主題較特殊,網路上前輩的範例文章較少,所以儘管筆者力臻完美,希望能在專注開發細節的說明同時,又能以清楚的敘述脈絡去表達我的所學,但恐有力有未逮之處,我也還在學習,還請多指教與回饋您的意見!

在接下來的內容中,筆者會分成 3 大部分撰寫:

  1. 前期的架構設計 & 技術選擇
  2. 開發功能的實作
  3. 本次接案後的心得與所學

前期:功能規劃 & 架構設計

筆者認為這次開發之所以能相對順利,是因為我有意識的調整一些以往寫 side project 的作法。這次我分配了較多時間在前期的功能和架構規劃上,如果當時我這次直接開始寫程式,開發上恐怕不一定會預期般順利,也不會有這篇文章。

因此本段落中,我想先針對我在前期針對架構跟技術做的一點功課做展開:

1. 功能規劃與延伸

這次的委託方團隊主要在打造給有出國交換的學生的論壇,而團隊希望當網站有使用者上傳新的落點後,能在 LINE 通知有訂閱該校系的其他使用者們。

而我在第一次開會時不斷追問委託方計畫功能的背後原因,瞭解團隊是為了彌補現行網站沒有站外觸及使用者的渠道,希望能透過這方式將使用者導回平台上。

對筆者來說,訂閱科系功能確實是符合委託方的導流目標,然而除了這點我會再去思考它所累積的資料還能對網站本身帶來什麼價值,亦或是核心目標的幫助?

像是我會想透過訂閱記錄,可以瞭解網站上使用者們有興趣科系和學群的分佈情形,作為實體活動的主題安排依據,或是未來個人化推薦通知的分析資料。

我會記下這些可能的應用,提供給團隊參考之外,我會先頗析除了基本要開的資料庫欄位之外,我還要事先存哪些資料?

我想傳達的點是:委託方給我們的規格跟功能需求是源於他們在業務上遇到的問題跟困難,而身為一個開發者,如果能進一步去頗析功能與問題的切題性,以及功能背後可以帶來的額外資料「 紅利 」,這對專案跟個人技能都有很大的幫助。

2. 架構設計

接著想分享一下本次架構的設計,雖然很大部分是銜接委託方原有的技術做開發,我只有負責 Chatbot 程式本身的架構,不過因為這次專案用到的 DB 很特別,因此也會簡單稍微介紹:

Language

我這次是以 TypeScript 開發,一方面是原團隊的 tech stack 也是用 TypeScript,考量後期他們要接手維護的話會更加高效;另一方面 Node.js 原生的非同步和 EDA ( Event-Driven Architecture ) 等特性真的很適合聊天機器人這樣的專案,配上強型別後開發流程也更快速。

只是網路上用 TypeScript 寫 LINE Bot 的文件或範例並不多( 大部分是使用 Python )所以我是自己看 LINE SDK 文件的說明以及其他相關 package 的文件開發的。

LINE 的 Node.js 官方開發文件中有提供 TypeScript 型別資訊

Platform

我是把服務部署在 Google Cloud Function,以 Serverless 的方式部署。其實 chatbot 本身就是一個事件驅動 ( Event-driven ) 的軟體架構,相比架設 VM 或容器化,Serverless 更適合用在 chatbot 這樣流量分散且執行時間短的服務。

而 Cloud Function 就是 GCP 的 serverless 服務之一,除了能即時根據流量自動擴展執行的個體之外,成本也只會依照啟動後的實際資源用量計算,我現在的 chatbot project 大多都是部署在 Cloud Function 上。

之所以會知道用 Cloud Function 來部署 serverless,是因為我高中時寫了一個有上萬使用者、卻用 App Script 建的簡陋 side project,那時還沒有 Container 或 Serverless 的觀念,想要找更合適的部署方法,受到戴均民前輩的指點後才開始認識雲服務跟 Serverless,算是個很有趣的故事。

如果想更深入了解 Cloud Function,推薦閱讀戴前輩的這篇文:Heroku 取消免費方案?教你用 Cloud Functions 架設 LINEBOT! | 筆記國度

Database

而 DB 方面我是沿用委託方使用的後端服務平台 Supabase,它直接整合 PostgreSQL、Auth、Restful 等開發者會用到的服務,可以省去自行架設後端資料庫的工作,很適合中小型專案的開發者使用。

本次專案使用的 BaaS

對於只有自架過 MySQL 的筆者而言,這次用 Supabase 讓我的開發體驗提升了一個檔次,我可以在 chatbot 上使用 Supbase 的 SDK API 直接做 CRUD,不用重複寫 query,像是:

const { data, error } = await supabase
  .from('users')
  .select();

還能在 DB 端設定 trigger 或 cron-job 發送 request 到指定的 webhook,讓我在以事件驅動架構的開發上方便很多。

開發過程的實作

接下來筆者會深入分享我個人的開發流程,以及是怎麼透過程式實現「 綁定網站帳號 」跟「 傳送推播通知給使用者 」這 2 個功能需求的。

0. 筆者的開發流程迴圈

想先簡述一下我開發每個功能的大致流程,也是我寫 full-stack side project 時的方法:

  1. 先開 DB table 並寫好 SQL function
  2. 先寫後端功能的測試案例
  3. 撰寫 chatbot / 後端程式( 解 bug )
  4. 開發網站前端 + 串接後端
  5. 跑測試 + 紀錄下次 loop 可以改善的地方
對,沒錯,我是先寫測試再開發主程式

在同時負責前、後端的情境下,筆者習慣先從功能會用到的資料出發,我會觀察可以共用哪些 DB 既存的資料?這次功能需要新增哪些欄位?其實我這裡只是在思考各種資料的存在必要性 ( why ) 以及盤點會有哪些資料 ( what ) ,之後寫後端時處理這些資料( how )會輕鬆一些。

我喜歡從 DB 開始著手開發,釐清有哪些資料再想怎麼處理它們會快很多,圖為 side project 的資料表

再來我會針對重要的業務邏輯跟功能先寫好測試案例,像是我會先寫落點資料排序 function 的測試案例,而不是先寫排序的 function 本身。我預想輸入的資料參數會長怎樣,以及各個 function 的最終輸出應該長怎樣,先寫測試讓我能更好得從 function 最終的結果回推出執行的過程跟步驟。

line bot 單元測試截圖
圖為我在 side project 寫的 test case

「 先寫測試再寫函式 」是筆者先前在一個討論 unit test 的技術分享會上學到的觀點,有點以終為始的概念,將工程師從產出結果回推演算法的過程具象化,同時也能確認我想保護的程式邏輯在未來其他的改動後也能正常運作。

此外,在小專案中並不用要求 100% 的程式都要有測試,而是專注在至關重要的業務邏輯跟核心功能上就檢查就好,像還是有些次要的函式測試我也是開發事後補上的。

另外一個特別的點是:我會在每次開發完一個功能後先紀錄這次學習到的技術,跟下次可以改善效率的地方,讓這次的技術輸出成為下一次流程的先備知識輸入,像是下次可以縮短優化算法的投入時間、測試案例的涵蓋情境可以再增加等等,筆者在最後一段會更詳細的提供實例。

這大致是我在開發下面 2 個功能的流程步驟。

1. 綁定使用者的 LINE 與網站帳號

在讓使用者訂閱科系、發送 LINE 通知之前,首先要先讓使用者能綁定 LINE 到原網站帳號上,白話文來說,就是要知道 LINE user 的 uuid 是對應到哪個網站的帳號,之後才能找到訂閱使用者的 line uuid 進行推播。

Thought

以下是我在開發綁定帳號之前所想的幾個問題:

  • 已知 LINE uuid 能在 LINE Bot 上取得,而網站帳號 user id 則是在網頁登入時取得,目標是知道彼此的對應關係
  • 怎麼在網站上得知欲綁定使用者的 LINE uuid,以此在網站登入後將 LINE uuid 跟網站帳號的 user id 建立綁定關係?
  • 不希望將 LINE uuid 直接暴露在使用者端的話,可以怎麼做?

實作

因為前一份實習有做到類似的串接功能,所以當實際記下問題的同時已經大致想到實作方法了,設計的流程如下:

  1. 使用者在 LINE Bot 傳送「 綁定 」關鍵字,LINE Bot 生成一次性 token 存在 DB,並將 token 以網址參數形式夾帶在訊息按鈕的連結中
  2. 使用者點擊連結後進到網站登入畫面,透過網址參數取得 token 並暫存在 cookie,重新轉址移除 token 參數
  3. 使用者登入網站帳號後,將網站帳號的 user id 跟 cookie 暫存的 token 一起傳到 DB
  4. 在後端根據 token 找到對應的 LINE uuid,與網站 user id 建立綁定關聯

我是以 token 的機制去代替 LINE uuid,並以網址參數的方式讓 token 能從 LINE App 轉到瀏覽器做暫存,所以 LINE Bot 程式對綁定事件的簡易流程是這樣:

async function handleBindingMessage(lineId) {
  const token = await generateToken();
  await storeTokenToDB(lineId, token);
  // 將 token 打包在 Flex Message 的按鈕 URL 中
  return generateBindingMessage(token);
}

而網站前端則是負責取得登入後的 user id,跟 token 一起送到後端去做綁定關係的 update:

async function handleBinding(userId, token) {
  const hasBinded = await checkLineBinding(userId);

  if(hasBinded){
    return AlreadyBinded();
  }

  const token = getCookie("line_binding_token");
  await setUserBindingStatus(userId, token);
  return BindingSuccess();
}

當然,如果你是熟悉 LINE API 的開發者,應該也會認為透過 LINE 的 LIFF 取代這裡的 token 機制是更好的作法,確實如此,我也會在最後一段會說明細節跟原因。

另外嚴格上來說,這裡提到的後端其實是直接打 Supabase 的 API,這次網站並沒有一個 back-end layer 來驗證 front-end 送來的資料,而是直接從 front-end 跟 Supabase DB 做資料交換( 不過這就是 Supabase 的本意:極簡後端的建置工作 )

所以對於資料更新跟創建的權限問題,我是透過 PostgreSQL 的 Row Level Security 來實現的( 設定每個資料表個別 CRUD 行為的執行權限 )另外個人也有設計措施在 DB 端驗證前端送來的 token 是否有效,細節因為涵蓋到專案的資安就不贅述了。

2. 將新落點做 LINE 訊息推播

介紹完怎麼取得使用者的 LINE uuid 後,接著就可以建立新落點的通知推播功能了,筆者會專注在我對 Supabase 跟 LINE Bot 之間事件流的設計,就不贅述我怎麼做新增訂閱頁面的前端工作( 只想說相比 React 我還是習慣 Vue 啊 XD )

Thought

針對傳送推播訊息的功能,我在技術面的想法有以下兩個:

  • 怎麼降低 LINE Messaging API 主動推播費用?
  • 怎麼減少 Serverless 對 DB 請求次數?
Realtime vs Cronjob

一般的 LINE Bot 都是採用「 即時通知 」在有新資料的當下馬上傳送通知,像是提款後銀行 LINE 官方帳號會馬上傳訊息給你。

但校系落點並沒有分秒等級的時間敏感度( 如果是傳申請結果給申請者就另當別論 )加上在審核結果公佈的期間採用 realtime 模式的話,用 realtime 方式推播暴增的落點資料可能會產生不少 LINE Messaging API 的推播費用。

因此我跟團隊建議採用類似 crob-job 定期執行的做法,以每小時為區隔,自動將這小時新增的落點資料傳到 LINE Bot 程式推播,這樣在落點上傳暴增時,在一小時內新增多個相同校系落點也只需要推播一次。

要自首這並不是典型的事件驅動架構設計,一般的事件驅動架構是要在資料新增時馬上呼叫 LINE Bot 推播,這裡採用 cron-job 主要是考量到團隊對專案的費用控制,cron-job 相比 realtime 在大量落點新增的情況下更節省費用。

另一方面是落點資料並不追求分秒級的即時性,所以設計了這樣有點混合的觸發流程。

cron-job 的做法也會衍伸一個缺點,就是因為 LINE Messaging API 可以傳送的輪播訊息長度有限,所以在使用者訂閱校系有非常多則新落點的情況下,有部分的落點沒法被顯示給使用者。

這部分我是根據委託方的設計去建立一個落點的排序規則,確保使用者能收到對他最為重要的落點,其餘的則導流到網站上查看。

推播群組的分類

決定好採用事件觸發的條件後,要思考的是 LINE Bot 怎麼處理推播事件、將 DB 給的落點資料有效率、盡可能低費用的傳給訂閱使用者。

我事先在 Supabase 寫了一個 SQL function,將 SELECT 取得的落點資料打包成 json 格式並組一個陣列,而每個 object 裡又有該落點所屬校系的訂閱者 LINE uuid 陣列,格式如下:

const results = [
  {
    id: "123",
    subscribers: ["user1","user2"]
  },
  {
    id: "124",
    subscribers: ["user1","user2","user3"]
  },
  {
    id: "125",
    subscribers: ["user3"]
  }
];

具象化的圖大概是這樣子,機器人收到的資料會是以落點為個體,稍微分類的話,可以知道綠衣男跟橘衣女會收到落點 1 跟 2,而眼鏡男會收到落點 2 跟 3,通常機器人會根據使用者數逐次寄給這三個人他們訂閱的落點資料:

然而,我理想是對使用者們依照同樣的訂閱結果進行分類,另外建立成一個個小的推播群組名單,就能在有多個訂閱者有同樣結果時,能打 2 次 API 就能寄給這三個人:

const multicastGroups = [
  {
    subscribers: ["user1","user2"],
    results: ["123","124"]
  },
  {
    subscribers: ["user3"],
    results: ["124","125"]
  }
];

可視化後會像這樣子:

line bot 推播演算法解釋圖

依照收到的訂閱結果分類相比逐一寄給使用者,可以減少 Messaging API 的呼叫次數,雖然依照推播人數為計費單位的 LINE API 的費用不會改變( 以上面的情境來看,還是算 3 個受眾的費用 )但這能減少 Cloud Function 呼叫外部 API 的次數跟衍生費用。

只不過若要以 SQL 組出這樣的分類會有點複雜,效能上也不見得是最好的,所以我決定在 chatbot 程式中寫個簡單的演算法分類出我想要的格式,下面會簡述我寫的小演算法。

實作

Supabase 的 PostgreSQL 預設有許多方便的擴充功能,可以發送 http 請求跟建立 cronjob( 你沒看錯,你可以寫 SQL query 用 DB 跑 cronjob 跟打 API )首先是建立打 POST 請求、把落點資料傳到 Cloud Function 的函式 :

DECLARE 
    _result jsonb;
    _response text;
    _payload jsonb;
BEGIN
    _result := get_results(); // 我寫的 select 落點函式 
    _payload := jsonb_build_object('results', _result);
    _response := http_post(
        "API endpoint URL", 
        _payload::text,
        'application/json'
    );
END;

而下面是建立 cronjob 的 query,需要留意的是 Supabase 的資料庫時區預設是 UTC +0,而官方也建議維持預設值,所以特定時間執行的 cronjob 時間要以台灣時區減 8 小時才是正確的:

SELECT
  cron.schedule(
    'hourly-multicast-task',
    '0 1-13 * * *', // 這裡就是台灣時區的 9 點到 21 點
    $$
    select post_request();
    $$
  );

這樣 Supabase 就能在指定時間段內發送請求到 LINE Bot 了,對於不是在推播時間段新增的落點,會寫另一個 function 去選取較長時間段內的落點資料。

再來是 LINE Bot 排序的實作部分,依照收到同樣訂閱通知的使用者們分類成群組,首先將 results 轉換成使用者的訂閱清單,LINE uuid 為 key,value 則是該使用者會收到落點編號陣列:

const userSubscription = {
  "user1": ["123","124"],
  "user2": ["123","124"],
  "user3": ["124"]
};

一般做到這裡就可以推播了,只要遍歷 object 即可,但我想再更有效率一點,所以再建立了一個新 object,key 為落點編號陣列合併成的字串,value 則是該落點結果群組的訂閱使用者:

const groupsObject = {
  "123,124": ["user1","user2"],
  "124": ["user3"]
};

最後將 object 轉換成陣列即可,也就是我在構想上預期的格式,其實這裡只是遍歷 object 將 key split 作為 results 而已~

const multicastGroups = [
  {
    subscribers: ["user1","user2"],
    results: ["123","124"]
  },
  {
    subscribers: ["user3"],
    results: ["124"]
  }
];

接著只要對上面產生群組名單逐一推播,這樣使用者就能在單次通知裡收到這一小時新增的訂閱校系落點:

for(const group in multicastGroups) {
  const resultsToSend = results.filter((r) => group.includes(r));
  const carouselMessage = generateResultFlex(resultsToSend);
  await line.multicast(group.subscribers, carouselMessage);
}

上面我是簡化成一般迴圈的寫法,在專案中筆者是用 Promise.allSettled 同時執行打 LINE API 的異步函式,因為 API 結果是彼此獨立的。

可以做更好的地方:使用 LIFF 進行綁定

在帳號綁定的開發實作段落中,我有提到用 LINE 的前端框架 LIFF 會比自己做的 Token 機制會更好一些。原因在於可以減少自己實作 token 的必要性,還可以用 LIFF 給的 Access token 在後端取得 user id,減少前端資料竄改或竊取 token cookie 的風險。

當時因為我不熟悉 LIFF 所以沒有選這個方案,但以 LIFF 取代自己的 token 驗證機制是我下一階段以及日後類似專案會採用的作法,畢竟論資安跟 LINE API 的整合上是更為理想的。

這次收穫的 3 個所學

除了技術上的產出,筆者也在本次專案後做了一些自我反思,綜整了我覺得很想分享的所學,包括優化的時機點、對細節的追求程度,以及工程師如何在自己或委託的專案中發揮價值。

寫專案剛開始,不要花過多時間做優化

在安排開發的工序優先級時,我常以本科( 管理科系 )的角度去排序,我會以開發這個功能 / 修正這個問題的影響範圍,去除以所需要的時間得到的比率來做排序,先做槓桿率高的任務。

像是開發綁定帳號會影響到其他所有 LINE Bot 功能的進行,我就會先去做綁定帳號的程式,而不會先去開 Cloud Function 的測試服務。

我自己的槓桿率算法

而優化程式也是一個有「 邊際效應 」的工作,意指當優化到一定的程度過後,所帶來的效能提升跟投入時間的比例會越來越低,原本一小時的優化能減少 30 % 的回應時間,到後面同樣花一小時,卻可能只會帶來 5 % 的提升。

產出影響跟投入時間的比率會越來越小

筆者就花有點過多的時間在做改變不明顯的優化上,例如筆者在寫綁定帳號的 React 前端時,我為了加快渲染效能並最小化的外部 API 的呼叫,不斷在重構元件跟 hooks 的寫法,但其實原本我的寫法就足夠好了。

如果我先優化到一個可以接受的地步,並開發落點通知的推播,事後一起針對最為重要的部分做優化的話,這次專案是可以更早完成的。

「 在剛開始開發時,怎麼克服追求效能最佳化的慾望,並以產出比率來排序更合理的開發工作 」是我在做完綁定功能後的自我警醒,所以在開發推播通知時,我先專注在分類函式跟外部 API 呼叫的優化上,等到功能完成後再進一步透過資料結構來優化其他細項。

這裡呼應到我前面提到開發步驟迴圈的最後一點概念,我在每次開發完一個小功能後,會反思應該修正的錯誤或可以做更好的地方,並套用在下一次的功能開發裡,避免再犯重複的錯誤。

我並不否定優化的價值,重點在於優化的機會成本是否過大,以及功能是否會 / 已經因為使用量的增加而面臨到效能瓶頸( 像是 xx 售票系統網站 )引用一句我最近在臉書轉傳圖片看到網友留言:

技術的價值,短期被高估,長期被低估

掌握好專案開發的進程、同時在合適的時機點適度改善,能不斷讓專案的技術逐漸不重要,是很重要的事,一點點個人的鄙見。

寫程式前不用想一步到位,驗收前不能放過細節

筆者有個很不好的毛病,寫功能的 code 之前常常會想很多複雜的情境跟問題而不敢下手,要 deploy 前卻往往會不注意到設計或規格上的細節。

像是我在寫 React 前端頁面的時候,有些細部的操作流程跟 Description 為了開發方便就會先做的比設計稿簡略,到了要準備驗收時自己才想起當時有很多小細節都沒注意到( 最糟糕的是頁面說明文案沒打完整 )

這次專案也是讓自己重新正視這個問題,也是逐步要求自己平衡開發前、後對細節的要求程度,希望能抓到更好的平衡點。

工程師的價值:做出超越期待的產出

這次專案中我多做了很多原本委託團隊沒有要求、但有助於後期團隊維護的額外工作,像是用 GitHub Action 做 CI、寫單元測試等等,因為我對自己的期望是「 發揮超越期待的價值,做出超越期待的產出 」這是個人認定一個工程師的工作價值所在。

「 做出超越期待的表現 」是我今年在 Podcast 節目聽到一位技術轉管理的前輩分享的心法,舉節目提到的例子:很多工程師時常會遇到開規格寫不清楚的情境,一般會請 PM 釐清跟補足細節再開發( 很幸運這次沒有遇到這問題 )然而這些不清楚的地方,工程師若能透過主動跟對應部門溝通協調,就能做出超越他人預期的表現。

一部分是因為平常在學校讀書,我沒有什麼時間去另外練習新學到的技術,所以每一次的實戰對我而言是個珍貴的練習機會,另外一部分是我抱持著為自己而寫的心態去寫專案的,比起完成專案的成就感,我更在意這過程之間有沒有技術成長。

「 做出超越期待的表現 」是筆者在寫程式,跟參與有興趣的團隊專案時所奉行的圭臬,我不過度期待他人的產出,但嘗試透過溝通、精簡工作步驟、分享,我會盡可能去在每一次的產出中學習創造更多價值,至今也還在學習這件事。

總結

這一次的委託可以說是現在的我幾乎不留遺憾的產出跟展現了,這並不是對於當下狀態的自矜,而是在撰寫這篇文章的過程中,明顯感受到自己跟高中純粹興趣使然的差別,無論是在觀念、技術都更沈穩跟嚴謹一點,對架構的設計上也有更多的選擇跟應對方案。

也可能是因為進大學一年後的緣故,很清楚知道我不會是資工系統中的強者,像是在刷 Leetcode 或學校程式大作業時就會覺得寸步難行,因此對於實務面的應用會對自己有更嚴苛的要求,期望做出超越自己期待的表現。

想分享給看到最後、學完 LINE 機器人的讀者一句話:

知識來源於經驗,經驗源自於實踐

筆者相信技術的價值並不只在於量的積累,也在於能否解決問題跟創造影響力,希望能在這篇文章的分享能帶給你一些思維上或技術上的幫助,筆者也很期待未來能使用到更多同好跟前輩們打造的應用與服務,make good impacts to this place。

Special Thanks

感謝 4x 學長與周奕勳 Max 學長在開發階段時與我討論很多技術跟心態思維,讓我在撰寫本文時更有信心與想法,以及感謝 chatbot.tw 諸位前輩對我的指教與熱心分享,讓我能夠學習跟觀摩 chatbot 的技術與應用,才有機會去發揮所長。

前一篇文章
github action cloud run自動部署教學

提高開發效率!用 GitHub Action 自動部署 Docker 容器到 Cloud Run 教學