Learning Objectives
- 說明每則訊息都是新的請求,以及為什麼 webhook 不能靠 Cookie 或 Flask 的 session 記住使用者
- 用 user_id 當鍵保存每個人的狀態,並說明存在字典裡的問題
- 把多步驟流程寫成狀態機,存進 SQLite,處理取消與錯誤輸入
- 存歷史訊息時只存需要的欄位,查詢帶 user_id,並讓使用者可以刪除自己的資料
下一句話是新的請求
使用者想預約討論室,機器人問「想預約哪個時段?」,使用者回「週三下午」。對你的程式來說,這兩句話是兩個毫不相干的 HTTP 請求:處理完第一句,on_text 就結束了,區域變數都消失了。收到「週三下午」時,程式不知道它是在回答什麼。
做網站時,這個問題靠 Cookie 或 Flask 的 session 解決(後端軌第 14 課)。機器人不能這樣做:webhook 是 LINE 平台送來的,不是使用者的瀏覽器,請求裡沒有屬於某位使用者的 Cookie。
每個事件都帶著 event.source.user_id,所以要自己準備一個以它為鍵的地方,記下「這個人走到哪一步、已經說了什麼」。這就是對話狀態。官方文件提到,使用者沒有同意提供個人檔案資訊時,webhook 裡不會有 user ID,真實的程式要能處理這個值是 None 的情況。
逐步看:用字典存每個人的狀態
最簡單的做法是一個全域字典。王小明和林怡君同時預約,交錯傳訊息。按播放,注意右邊記憶體圖裡的 STATES,它在兩個請求之間留了下來,兩個人各有一份。
把狀態放進資料庫
字典還有另一個問題:正式環境常常跑好幾個 worker 程序(後端軌第 19 課),程序之間的記憶體不共用,同一個人的兩句話可能被不同程序處理。
所以狀態要放在程式之外。一張表就夠了,user_id 當主鍵,每人一列:
CREATE TABLE IF NOT EXISTS states (
user_id TEXT PRIMARY KEY,
step TEXT, -- 走到哪一步
data TEXT -- 已經收集到的資料,存成 JSON 字串
);
連線的寫法和後端軌第 9 課相同:get_db() 把連線放在 g,請求結束時關閉。handler.handle 是在 callback 裡呼叫的,所以 on_text 裡可以直接用。不過,如果照第 3 課的建議把事件交給背景執行緒處理,執行緒裡沒有 Flask 的應用程式情境,就不能用 g,要在執行緒裡自己開關連線。
更新狀態用 UPSERT:沒有這一列就新增,已經有了就更新。
INSERT INTO states (user_id, step, data) VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET step = excluded.step, data = excluded.data
excluded 指的是這次原本要新增的那一列。SQL 的語法可以到 SQL 軌學,不過那一軌用 MySQL,寫法不完全相同。
狀態機
把流程畫出來,每個步驟等於一個狀態,收到訊息就依規則換到下一個狀態:
| 目前狀態 | 收到 | 回覆 | 下一個狀態 |
|---|---|---|---|
idle |
預約 | 想預約哪個時段? | ask_time |
idle |
其他 | 傳「預約」開始 | idle |
ask_time |
任何文字 | 請問幾個人? | ask_people |
ask_people |
數字 | 確認嗎? | confirm |
ask_people |
不是數字 | 請輸入數字 | ask_people(不動) |
confirm |
確認 | 預約完成 | idle |
| 任何狀態 | 取消 | 已取消 | idle |
兩個容易漏掉的情況:輸入不合法時留在原地,讓使用者重答,而不是當掉或跳到下一步;隨時都能取消,不要把人困在流程裡。「取消」要在進入各步驟之前先判斷。
逐步看:狀態機與 SQLite
這段邏輯用不到 LINE:handle 收到 user_id 與文字,回傳要回覆的話,所以範例拿掉機器人的部分,只留狀態機,也省略確認那一步。為了聚焦,這裡只用一條連線;放進機器人時要換成上面的 get_db(),下一個互動練習就是完整的版本。
每次都會印出:使用者傳的話 → 回覆,以及 states 表的內容。
歷史訊息與使用者的資料
除了狀態,機器人也可能需要留下訊息紀錄,例如讓使用者查自己說過什麼。一張表,每則訊息一列。
存之前先想過這三件事:
- 只存需要的欄位。 這裡只存
user_id與訊息內容。顯示名稱、大頭貼這類不需要的資料就不要存。user ID 可以對應到一位真實的使用者,要當成個人資料保管;使用者傳的訊息裡也可能有個資 - 查詢一定要帶
user_id。SELECT msg FROM messages會把所有人的訊息回給正在查詢的人,這是真實會發生的外洩。LINE 的訊息一對一傳給你,容易忘記資料表裡是所有人的資料 - 讓使用者可以刪除自己的資料,並告訴他們你存了什麼、存多久。不再需要的狀態與紀錄也要定期清掉,例如走到一半就放棄的預約
另外,第 3 課提過 LINE 可能重送事件。對狀態機來說,同一句話處理兩次會多走一步,所以要照第 3 課的做法,用 webhookEventId 去重。
逐步看:每個人只看到自己的紀錄
兩位使用者各留一則訊息,再由王小明查詢。看 SQL 的 WHERE user_id = ?,以及印出來的結果裡有沒有林怡君的訊息。
動手做:只能看自己的紀錄,也能刪除
完成兩個函數。起始碼的 history 少了條件,會把所有人的訊息都查出來,執行一次就看得到。
history(uid):回傳這個人所有訊息內容組成的串列,依id由舊到新delete_mine(uid):刪除這個人的所有訊息並回傳刪了幾筆。別人的資料不能動
cursor.rowcount 可以取得影響的列數。記得 commit。
想一想
有人用一個全域變數 current_step = 'idle' 記錄流程走到哪一步。會發生什麼問題?
這段程式會印出什麼?
STATES = {}
def on_text(uid, text):
state = STATES.setdefault(uid, {'step': 'ask_time'})
if state['step'] == 'ask_time':
state['time'] = text
state['step'] = 'ask_people'
return '幾人'
del STATES[uid]
return state['time'] + text
print(on_text('A', '週三'), on_text('B', '週四'), on_text('A', '3'), len(STATES))
問人數時,使用者傳了「不知道」。最合適的處理是?
INSERT … ON CONFLICT(user_id) DO UPDATE SET step = excluded.step 裡的 excluded.step 指的是什麼?
機器人要提供「想看歷史訊息」。下面哪一句 SQL 才對?
重點整理
- 每則訊息都是新的請求;webhook 來自 LINE 平台,不能用 Cookie 與 Flask 的 session,要以
user_id為鍵自己保存狀態 - 字典重新啟動就不見,多個程序也不共用,所以存進資料庫
- 多步驟流程寫成狀態機:輸入不合法時留在原地,「取消」最先判斷
- 只存需要的欄位,查詢帶
user_id,讓使用者可以刪除自己的資料
下一課讓機器人查外部資料、接上語言模型,並處理等待與失敗。