Blog Cover Image

Inspire you to have New thinking, Walk out your unique Road.

[產品使用技術][Python] Miri的前端與後端接口、整體程式架構介紹 | FastApi x Line Bot x Telegram Bot | 開發概念系列(1)

Posted onSep 6, 2021

由於本人健康問題,再加上這幾天有其他事情比較重要,還有就是一下把開發感想寫完,寫文章的靈感突然全無,所以跟技巧還有概念設計相關的文章就拖到了現在才有靈感撰寫。

再將近一週內心的沈澱後,我想應該可以好好的撰寫本系列的文章了,我應該會分成三到四篇來撰寫。

這篇主要介紹的是 Miri 的前端設計,以及和後端 API 接口的程式設計。

整篇文章非常的長,以下為整篇文章的目錄

目錄如下:

  • 概念: 前端設計
  • 重構: API 及後端整體架構設計
  • 實作: 期望與模擬的後端 API 層架構實作
  • 實作: FastApi (含 Heroku 部署程式碼)
  • 實作: Line Bot
    • 事件 Event
    • 訊息型別 Message Type
    • 實作: Api & handler
    • 選單 Menu
    • Line Bot 的一些注意細節
  • 實作: Telegram Bot
    • 訊息處理者 Handler
    • Message Type 訊息型別
    • 創建一個 Telegram Bot
    • 實作 Api & Handler
    • Telegram Bot 的一些注意細節
    • 選單 Menu
  • 困難克服: 訊息於不同平台的呈現方式

那麼就開始吧!

概念: 前端設計

Miri 在之前跟第三版本的規劃中,是沒有要將前端獨立出來開發的計畫,因為本人當初還只是很菜鳥的後端工程師,連後端開發都吃力了,要怎麼去學習前端。所以從 Miri 剛開始出生時,我就是先是打算利用Line Bot Api的功能,讓 Line 代替前端的功能,使我能夠更專注的在開發後端,將 Miri 做成一個 Line 的聊天機器人。

但是,在規劃第三版本時,我希望能夠朝向更國際化的市場前進,說白了一點就是希望能找國外的工作,讓外國的面試官看到這個作品,所以想要新增其他新的 Bot 平台,畢竟 Line 目前還是在東亞洲地區比較興盛,除此之外的地區都不是 Line 的主要地盤,所以若是要開發一個面向英文使用者的平台,就要另外找其他通訊軟體的 Bot 功能。

最一開始我有想過What's AppFacebook 的 Messenger,但經過研究跟實際實作時,我發現要開發起來十分困難,會有些障礙,而且比較難克服。在一個外國朋友的推薦下,後來選擇了Telegram當作第二個可以連接到 Miri 的平台。

仔細看了 Telegram Bot Api後,發現他有和Line Bot Api相似的功能,也許在圖像化或者前端畫面豐富性沒有像 Line 那樣的齊全,但作為 Miri 另外一個連接的 Bot 平台,功能也夠了。

(才怪,我當初可是擔心死了,害怕在 Line 上能呈現的效果,在 Telegram 上到底要怎麼呈現 qq)

重構: 後端及 API 架構設計

有了要接兩個不同前端的平台的邏輯後,後端的程式架構就必須得做一些改變,我們先來看看第二版以前的 Miri 程式架構。

在第二版以及之前的架構中,由於本身還只是年幼的後端工程師,再加上所經歷的專案跟產品經驗不多,也沒有單獨設計程式架構的經驗,所以在當時連 Api 是什麼都很吃力理解的狀態下,硬是兜了簡單的架構。(就是隨便做)

從圖中可以看到的是第二版是使用Flask的框架來實作,而由於當初的前端平台只有 Line,所以就把 FlaskLine 的程式碼混在一起。

後端功能上非常簡單,當初並沒有需要紀錄帳號的功能,也沒有像現在這樣需要不同平台,所以基本上 Line 接收進來的訊息,直接丟進NLTKJieba切詞後,去功能面簡單撈一下回答就把回覆送回給客戶端。

那麼看一下第三版的程式架構圖

比起上一個版本,由於要串接兩個不同的平台,所以在後端 API 中勢必得將不同平台的 API 入口分割開來,如圖中的架構,當然也因為為了相容兩種不同平台而做出的重構,在未來若有規劃想要開發出獨立的前端像是前端網頁或者app mobile行動端都可以相容,而不用再做出這麼大幅度的更動。

再來還有一個很重要的點就是,由於目前是有兩種不同的平台而來的訊息,收到的訊息模式跟型別也會不同,在規劃上會特地多一個 process的步驟,主要是用來處理

  • 將收到訊息統一處理為另外一種訊息物件可以不分平台差異傳送到後面的功能層面 取得回覆
  • 將從功能層面取到的回覆傳回指定平台的process處理成可以傳回客戶端的訊息模板/型態

這是後端其中一個比較大幅度需要更改的重構部分。

而另外一個部分則是功能層面,因為 Miri 在第三版本確定會往命理跟占卜方向前進,所以比較大的主題功能會落於命理及占卜,把這個部分作為主要功能,而另外一個溝通功能則是保留若未來需要類似聊天/溝通的功能,還可以在這塊規劃的地區做開發。

主要主題是命理跟占卜,所以基本上功能面就是依照這個主題去展開程式架構,然後由每個功能去單獨和資料庫互動取出需要的資訊,詳細情形就不多說。

還有一個功能在圖中沒有呈現,就是關於系統層面的功能,像是帳號管理使用者引導切換語言等其他功能,也會一併規劃在功能層面

實作: 期望與模擬的後端 API 層架構實作

我的程式碼沒有公開,也不會在部落格撰寫具體是怎麼實作,所以我設計了一個貼近真實實作的程式碼架構模擬,Miri 內部大致上也是照著這樣去實作的。

接下來的部分就是實作後端 API 層架構層,那麼期望的實作架構與程式碼如下:

Miri
├── api
│   ├── line
│   │   ├── __init__.py
│   │   └── process.py
│   └── telegram
|       ├── __init__.py
│       └── process.py
│
├── features
│   ├── divination
│   │   └── ?
│   ├── conversation
│   │   └── ?
│   └── system
│       └── ?
│
└── Procfile
└── main.py

規劃上會將FastApi的程式碼實作在 main.py 作為 Api 接口的大門,然後 line 跟 telegram 會分開放在api資料夾中,分別將 Api 程式碼寫在各自資料夾的__init__.py中,在用FastApirounter功能將分開在不同檔案的 Api 路徑註冊進 api 的名單中,這樣即便 api 路徑在不同檔案,也能一併成為接口,接收 request 啦!

實作: FastApi

這個部分要換成Flask還是Django或者其他的 WebApi 套件都可以,基本上會使用 FastApi 主要只是因為要離職時同事跟我說過這是目前最快的 Api 套件,基於好奇,就直接拿來使用在 Miri 身上,但我目前在 Miri 身上使用到 FastApi 功能沒有很多,所以就算之後要替換成其他 WebApi 套件,也很簡單。

在專案的根部底層創立main.py成為整個程式驅動的主程式,也就是說當架設於伺服器上時,只要跑main.py就能將整個程式跑起來,將 api 架起來,前端就能使用了

main.py

import uvicorn
from fastapi import FastAPI

from api import line, telegram

app = FastAPI()

app.include_router(line.line_api)
app.include_router(telegram.telegram_api)


@app.get("/")
def read_root():
    return {"Hello": "World"}

# Production Setting
# Run on Heroku/ Procfile, run terminal directly
# web: uvicorn main:app --host=0.0.0.0 --port=${PORT:-5000}

# Development Setting
if __name__ == "__main__":
    uvicorn.run(app, host="127.0.0.1", port=8000)

原則上和 Flask 用法很像,先初始化一個app,而這個app就是代表整個程式及 api 的出入口,然後是將之後會寫在 line 跟 telegram 的 api 路徑註冊進app中,這樣即便 api 沒有寫在主程式,但也會自動將處在不同資料夾的 api 列進能使用的 api。

# 初始化一個app,代表整個專案程式的web入口
app = FastAPI()

# 將分散在line跟telegram的 api rounte 註冊進app
app.include_router(line.line_api)
app.include_router(telegram.telegram_api)

再來寫了一個hello_world來測試架起來後可否呼叫到 api,接著是當正在撰寫程式碼時,需要開啟 debug 模式所寫的設定。

@app.get("/")
def read_root():
    return {"Hello": "World"}

# Production Setting
# Run on Heroku/ Procfile, run terminal directly
# web: uvicorn main:app --host=0.0.0.0 --port=${PORT:-5000}

# Development Setting
if __name__ == "__main__":
    uvicorn.run(app, host="127.0.0.1", port=8000)

最後要提及一下,之後怎麼部署於 Heroku 伺服器上,當所有程式都完成了,要部署於 Heroku 上時,需要把用於開發真錯的程式暫時註解掉。

# Development Setting
# if __name__ == "__main__":
#     uvicorn.run(app, host="127.0.0.1", port=8000)

接著需要在根部底層創一個 Procfile 檔,是部署 Heroku 時,會使用到的部署檔,沒有副檔名!!

接著直接將一行程式貼進Procfile就好

Procfile

web: uvicorn main:app --host=0.0.0.0 --port=${PORT:-5000}

在 Heroku 中,是將程式執行於 0.0.0.0 並非 127.0.0.1

之後使用 Github 連接自動部署就能自動將 FastApi 執行起來。

FastApi 的部分就到這為止,我並沒有使用太多功能,只是拿來當 web api 的大門接口而已

實作: Line Bot

再來進到 Line Bot Api 中,我一直在想要怎麼撰寫 Line 跟 Telegram 的教學會比較好,因為其實網路上已經蠻多人撰寫這方面的文章。

但我還是根據自己使用到的功能,加上一些開發需要注意的地方來撰寫此篇幅。

這個是我自己畫的架構圖,基本上最左邊是 FastApi 的 api 大門,而 line 的 api 只有一個,就自訂吧,我這邊是定為/api/line/callback。可能會想問,如果 Line 的api只有一個,那要怎麼分辨不同的訊息?這個時候事件Event訊息型別MessageType就很重要了。在 Line 中即便 api rounte 路徑只有一個,但主要是依靠訊息的事件Event訊息型別MessageType來接收跟回覆不同的訊息。

事件 Event

Event 是比訊息還上一層的概念,我們來看看 Line 中有哪些事件:

  • MessageEvent
  • FollowEvent
  • UnfollowEvent
  • JoinEvent
  • PostbackEvent

還有一些我沒寫上去,基本上 Event 比較像是加入群組, 離開群組, 訊息動作或其他類似的動作,也就是說一種Event需要一個或多個handler處理程式去處理它,如果你要做的 Event 或者接收到的 Event 沒有特別寫 handler 來處理的話,後端程式也就不會接收到傳過來的 Event 事件,訊息就會卡在 api 層然後報錯。

而我基本上只用到兩個 Event: MessageEvent, PostbackEvent,所以只寫了 3 個 handler:

  • MessageEvent, message=TextMessage
  • MessageEvent, message=StickerMessage
  • PostbackEvent

剛剛有提到一個 Event 需要一個或多個handler,主要就是在於MessageEvent能有不同的訊息型別Message Type,而每個訊息模式也都需要有一個 handler 來處理。

訊息型別 Message Type

Line 的訊息型別蠻多種的,詳細的型別給大家列在下方

Message types

基本上,從 Api 最開始收到的會是一種 Line 的訊息型別(Message Type),而當程式要回傳給 Line 客戶端時,也必須要將訊息包裝成 Line 的 Message Type,才能回傳給客戶端。 所以就是一律用 Line 的 Message 物件去做溝通傳遞,也請放心即便傳來的是Message物件,裡面也一定夾帶一堆參數,可以讀取其中的訊息或檔案。

在 Miri 當中,我大量的使用 TextMessageTemplate message

TextMessage指的是文字訊息,所以如果只要發文字訊息的話,只要將字串包成TextMessage物件再發出去就可以了

Template message中,我在實作占卜流程時大量運用到了:

這兩個訊息點擊按鈕後觸發的事件叫做PostbackEvent,所以如果有需要用到Template message來回覆訊息的話,記得加上PostbackEvent的 handler 來處理使用者按下按鈕之後的操作。

總之 Line Bot 也是一門有趣的學問,我可能只用到了 20-30%Line Bot 的功能來實作 Miri 的前端,有興趣的人可以多加鑽研,而我的文章也沒有說撰寫的很詳細,因為主要是紀錄 Miri 使用了哪些功能,不過只要有架構圖跟 api 的實作做完後,基本上後續的施工就依照每個人需求不同的功能各自去研究。

實作: Api & handler

以上講了很多,接下來就實作 api 的部分,來看一下上面的架構圖

Miri
├── api
│   ├── line
│   │   ├── __init__.py
│   │   └── process.py
│   └── telegram
|       ├── __init__.py
│       └── process.py
...
...
└── Procfile
└── main.py

先前已經將 FastApi 實作於main.py,這次要將 Line Api 實作於 ./api/line/__init__.py 當中

./api/line/__init__.py

import configparser
from datetime import datetime

from fastapi import APIRouter, HTTPException, Request

from linebot.models import *

from linebot import (
    LineBotApi, WebhookHandler
)
from linebot.exceptions import (
    InvalidSignatureError
)

from log import logger
from process import processor, MsgEvent


# Load data from config.ini file
# 通常重要資訊不會寫死在程式碼,會用環境變數檔存著,所以用此套件讀取出重要資訊
config = configparser.ConfigParser()
config.read('config.ini')

# Access Token 跟 Secret 要去Line網頁中,你創的頻道內的設定觀看
# Channel Access Token
line_bot_api = LineBotApi(config['LINE']['ACCESS_TOKEN'])

# Channel Secret
handler = WebhookHandler(config['LINE']['CHANNEL_SECRET'])

# 這個部分就是開一個FastApi的Router,所有使用Router的Api Url都會註冊成為可以使用的api
line_api = APIRouter()

# 將 /api/line/callback 註冊進 line_api Router內
# 主要的api入口,接收所有的request
@line_api.post("/api/line/callback")
async def callback(request: Request):

    # get X-Line-Signature header value
    # 這個部分是要從header取得Line特有的參數或簽名參數,需要和Line官方比對,有誤就會報錯
    signature = request.headers['X-Line-Signature']

    # get request body as text
    body = await request.body()
    body = body.decode('utf-8', 'replace')

    # 這是我寫的logger, 可以刪除
    logger().debug("Request body: " + body)

    # handle webhook body
    # 透過接收到的訊息類型來分配到要進入哪個handler
    try:
        handler.handle(body, signature)

    except Exception as e:
        logger().error("ERROR: " + str(e))

    except InvalidSignatureError:
        HTTPException(400)
    return 'OK'


# 處理文字訊息
@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
    """
    TextSendMessage(text = type(str))
    """

    # 基本上各項元素都包在Event物件中
    user_id = event.source.user_id
    user_type = event.source.type
    send_time = datetime.fromtimestamp(event.timestamp / 1000)
    msg_type = event.message.type
    message = event.message.text

    # 這個我寫的,主要是因為我需要將傳來的事件統一變成另外一個我自己寫的物件,方便和後端溝通
    msg_event = MsgEvent("line", user_type, user_id, send_time, msg_type, message)
    response = processor.analyze(msg_event)

    # logger 也是我寫的
    logger().debug("Reply response: " + str(response))

    # 這一步就是回覆給客戶端,注意response 是 list型別
    line_bot_api.reply_message(event.reply_token, response)


# 處理貼圖訊息
@handler.add(MessageEvent, message=StickerMessage)
def handle_message(event):
    import random
    # 貼圖的部分沒怎麼做,就是從1-21個表情包隨便選一個回覆,使用者傳貼圖,我也回傳貼圖
    message = StickerSendMessage(
        package_id='1',
        sticker_id='{}'.format(random.randint(1, 21))
    )
    logger().debug("Reply response: " + str(message))
    line_bot_api.reply_message(event.reply_token, message)


@handler.add(PostbackEvent)
def handle_message(event):
    import json
    # 基本上 Template Message 傳出去前先要將每個按鈕的訊息壓成json
    # 所以Postback回傳的訊息會是json
    data = event.postback.data
    data = json.loads(data)
    user_id = event.source.user_id
    user_type = event.source.type
    send_time = datetime.fromtimestamp(event.timestamp / 1000)

    # 處理訊息的部分,你可以替換成任何你想怎麼處理訊息就怎麼處理訊息
    msg_event = MsgEvent("line", user_type, user_id, send_time, "option", data)
    response = msg_processor.analyze(msg_event)

    # 自己寫的logger
    logger().debug("Reply response: " + str(response))

    # 回覆給客戶端,注意response 是 list型別
    line_bot_api.reply_message(event.reply_token, response)

很長一段,但大部分的步驟都有寫上註解了,將幾個比較重要的地方寫出來。

  1. Line 的 Access Token 跟 Secret

這兩個 Token 能夠在你在 Line 網頁上管理機器人的頁面設定中找到,建議最好不要寫死在程式碼中,最好是使用類似.ini,.config,.env之類的隱藏檔案做開發,或者加密儲存在資料庫,因為當這兩個 token 被偷走後,機器人頻道就等於整個被偷走。

  1. APIRouter

記得在實作 Line api 接口時,要創建 FastApi 的 router,我們用 code 展現一下

line/init.py

from fastapi import APIRouter

# 我們在Line的api檔案中開一個FastApi的Router,用來將api串進fastapi接口,使其可使用
line_api = APIRouter()

# 將 /api/line/callback 註冊進 line_api Router內
@line_api.post("/api/line/callback")
async def callback(request: Request):
    pass

接著回到先前就已經實作過的 FastApi main.py

from api import line, telegram

app = FastAPI()

# 可以看到我們將上面在line檔案中實作的api rounter註冊進app中,使其可以使用
app.include_router(line.line_api)

# Telegram 也是一樣的道理
app.include_router(telegram.telegram_api)

  1. handler & event

根據上方程式碼,我們可以看到我寫了三個 handler


# 處理文字訊息
@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):
    """
    TextSendMessage(text = type(str))
    """

    # 基本上各項元素都包在Event物件中
    user_id = event.source.user_id
    user_type = event.source.type
    send_time = datetime.fromtimestamp(event.timestamp / 1000)
    msg_type = event.message.type
    message = event.message.text

    # TODO 看你怎麼處理他囉!但是要記得response要是list型別
    response = processor.analyze(msg_event)

    # 這一步就是回覆給客戶端,注意response 是 list型別
    line_bot_api.reply_message(event.reply_token, response)


# 處理貼圖訊息
@handler.add(MessageEvent, message=StickerMessage)
def handle_message(event):
    # TODO 處理
    line_bot_api.reply_message(event.reply_token, message)


@handler.add(PostbackEvent)
def handle_message(event):
    # 處理
    line_bot_api.reply_message(event.reply_token, response)

在每一個處理的函式中,你需要用裝飾器 decorator 來標示這個函式是一個 Line 訊息的 handler,接著要在 handler 的參數中標明這個 handler 是要處理什麼事件/訊息。

@handler.add(MessageEvent, message=TextMessage)

這個部分是回覆訊息,程式處理完的回覆記得將他依照需求變成 Line Message 物件,然後放進 list 中

line_bot_api.reply_message(event.reply_token, response)

簡單來說 response = [message_obj, message_obj]

接著,傳來的 event 會夾帶一些參數,大致上需要用到的參數都會有

def handle_message(event):
    # ---------------^ 就是這個傳來的event
    pass

基本上傳來的事件參數可得知:

  • 使用者 ID
  • 使用者型別, 私人或是群組
  • 傳訊息的時間
  • 訊息類別
  • 訊息本身

這邊要特別說一件事是 Line 的時間,我研究很久,因為他既很像 timestamp,但是轉換過來的時間又不正確,Line 的文件是寫 Unix Time,我最後是將讀到的時間除以 1000 再將其從 timestamp 轉為 datetime 才取到正常時間,不過是 UTC 時間,所以台灣時間是+8小時,記得要+上 8 小時再使用它。

選單 Menu

Line 的選單有兩種方式可以做成

  • Line 官方帳號管理網頁設定
  • 透過 Line Bot Api 設定

很抱歉讓你們失望了,身為後端工程師!!!

我沒有使用 Line Bot Api 做成選單 XD,因為當時我給自己實作 Miri 的時間已經超過了,再加上我覺得看起來選單這個功能如果用 Line Bot Api 做,不熟,看不懂,感覺很難 XD,在 Deadline 大敵將至,我還是決定用第一個方式做哈哈!

這是我做的選單,也是現在 Miri 在用的,也有被我朋友砲轟說是要做多簡單,乾脆他幫我畫!

畫畫不是我的強項!!!我用 Mac 的 KeyNote 拉出了我覺得可以接受的選單頁面就好,總之功能先到位比較重要!

如果是用Line 官方帳號管理網頁設定來做選單就會比較簡單,請到 Line 官方帳號管理網頁,登入後,選擇你之前創建的 Bot 帳號。

主頁 > 聊天室相關 > 圖文選單,接著就能找到創建圖文選單的地方。

點選建立,建立新的選單,比較重要的就是版型,目前官方提供像是圖中這麼多種版型,就依照個人需求選擇

我先隨便選了一個版型,接著右邊就有對應版型的動作類型可以選擇,然後左邊的設定也能夠上傳照片

按鈕動作提供了 6~7 種,但對我幫助比較大的只有文字,我還在妄想有沒有可能可以設定 CallBack 按鈕 qq,所以最後我做的選單只有按下去發送文字,然後 Miri 再根據文字去判斷要回送什麼訊息。

Line Bot 的一些注意細節

  1. 如果程式處理時間太久,會被 Line 判定超過時效,不會回覆使用者訊息

我常常會用 debug 模式去追我程式到底哪邊寫錯,有時候甚至會直接在錯誤的地方一步一步看傳遞的參數,結果就發現如果訊息太久沒有回給客戶端,Line 就會有時間到期的問題,而直接跳錯,不會回覆。

感覺這算 Line 比較嚴謹的地方,所以估計 Line Bot 後面的程式也不太適合拿來做大量的運算或者複雜的功能,目前沒測過最久能接受多久,但如果在 Debug 模式或者程式跑太久後報錯,可能就是反應時間超過 Line 定的時效囉!

  1. 可以一次回上限五個訊息

不管是 Line 還是 Telegram,回覆是使用 list 中可包含許多 Message 物件,所以代表可以回覆多個訊息,比方說小明對 Bot 丟了一個 Hello,則機器人可以回說: Hello 您好!, 請問要選擇哪個食物?, 食物的Carousel template message

response = [msg, msg, msg]

上限是五個訊息,超過就會報錯!

  1. Template Message 的字數問題

我承認!在開發時,我沒有好好看 api 文章 XD

要記得在開發 Template Message 相關的訊息型別時,參考一下 api 文件,因為他的每個參數都會有一些限制。

例如:

  • Carousel template 的 columns 最多只能放 10 個
  • Button template 的 button actions 最多只能放 4 個按鈕
  • Button template 的 title 最多只能 40 個字(40 個中文或日文字當標題應該完全足夠,慘的是英文字母,所以感覺 Line 不太適合給英文使用者開發 XD,因為我光要想辦法把英文要能詞達意又要限制在固定的字數中,花了很大的功夫)

總之就是開發時或者規劃時多看一下 api 文件就是!

但最令人羨慕的是 Button 的 Postback action 竟然可以多達 300 個字!!等於在 action 中塞一個很長一串的 dictionary 或者 json 都沒事,這個部分在 Telegram 非常麻煩,因為 Telegram 的 postback 有限字數 XDD

  1. 訊息處理失敗了就重新從前端/Line 訊息平台重新發一次 Request

這個部分基本上沒什麼問題,就是如果在開發時反覆測試,每次都從 Line 聊天室發訊息來後端測試功能,如果失敗了就是 request 直接失效,那就再從 Line 聊天室再發一次訊息。

為什麼提到這點,主要是因為 ...

Telegram 就算程式處理失敗了,發過的 Request 若沒有處理或回覆的話,短時間內將會一直流浪在網路上,直到你的程式寫好/修好之前,他都會不斷的是同一個 request 往程式的 api 發,不斷的發。

實作: Telegram Bot

接下來就來寫 Telegram Bot 的部分啦!

Telegram bot 是此次的新功能之一,我覺得它的 bot 機制和 Line 其實蠻相似的,所以實作邏輯不會有太大的問題,但訊息型別的精緻度會稍微比 Line 差一點點,還有一個特點是PostBackcallback_data參數字數不能超過 64 個字,所以幾乎隨便塞一個 dictionary 進callback_data,很快就爆了,方法我待會會在下面說。

Telegram Bot 說是和 Line Bot 相似也能說不太相似,總之邏輯都是 Bot,所以 Api 接口的寫法都蠻像的,但是深入核心的部分就會發現這兩種 Bot 會是呈現兩種完全不同的功能,倒也不是說不能整合,只是需要做一點轉換。

還有就是我發現我看不太懂 Telegram 的文件 XD

訊息處理者 Handler

和 Line 一樣,Telegram 也會有訊息 Handler,如果沒有撰寫函式處理相對應的動作或事件,Bot 也是一樣不會有反應。

這邊舉例兩個我用的 Handler:

  • MessageHandler: 顧名思義就是處理任何訊息的訊息
    • filter.text
    • filter.audio
    • filter.command
    • filter.document
  • CallbackQueryHandler: 處理任何透過按鈕觸發的 Callback 動作
    • handle_callback

這個概念跟 Line 很相似,MessageHandler是一個 Handler,但還是要特別標注他是處理哪種訊息,比方說 filter.text 專門處理文字,filter.audio專門處理音訊。

所以假設你要一個 Bot 處理文字檔案音訊,就變成要增加三個 Handler:

  • MessageHandler(Filters.text, handle_message)
  • MessageHandler(filter.document, handle_message)
  • MessageHandler(filter.audio, handle_message)

詳細程式碼下面會寫

Message Type 訊息型別

一些基本的訊息型別會有,包含文字圖像影片,還有一些其他的訊息型別

有興趣再麻煩參照 Telegram Bot Api 文件 啦!

比較讓我燒腦的是 Telegram Bot 沒有像 Line 的 Carousel Template的訊息,有提供的是InlineKeyboardMarkupInlineKeyboardButton型別,來看一下他的效果為何?

想不想來占卜一下? 哈哈

總之InlineKeyboardMarkupInlineKeyboardButton 是擁有按鈕的訊息,上面那個照片中其實是兩個訊息: Photo + (InlineKeyboardMarkup + InlineKeyboardButton),所以真實的效果是沒有照片的,而圖片中的整組訊息就是我想出來可以代替 Line 的 Button Template 訊息呈現於 Telegram 的替代方案,這個部分下方會說明。

總之,也許有其他訊息型別更適合,但就待各位去官方文件挖寶了。

創建一個 Telegram Bot

Telegram Bot 的創建和 Line 有一點點不一樣,Telegram 沒有像 Line 一樣完整的官方帳號管理頁面,也許本來這兩個機器人面對的客群就不同,要創建 Telegram Bot 你需要先找到他爸爸!

BotFather

不是跟你開玩笑吧 XD

進去之後,BotFather 就會寄很多創建 Bot 相關的指令,創建 Bot 跟刪除 Bot 蠻簡單的,所以如果操作不當就刪掉重創吧!

/newbot開始

  • 先取名字
  • 再取 username,他會像是唯一的@ id
  • 創建後,會給你一串 Token

比較需要注意的是 bot 的 username,就是類似 Bot 的 Id,用@包裝的 Id,這個名字一定要含bot,所以你可以取 TetrisBottetris_bot 就看個人發揮,就是要有 bot 字在裡面。

然後那串 token 很重要,是未來需要連接 Bot 到後端程式所需要的 Token,丟了也可以透過 BotFather 再創建,不用擔心!

接下來就進入到實作 APi 的部分!

實作 Api & Handler

接下來一樣進到實作的部分,來看一下上面的架構圖

Miri
├── api
│   ├── line
│   │   ├── __init__.py
│   │   └── process.py
│   └── telegram
|       ├── __init__.py
│       └── process.py
...
...
└── Procfile
└── main.py

先前已經將 FastApi 實作於main.py,這次要將 Telegram Api 實作於 ./api/telegram/__init__.py 當中

./api/telegram/__init__.py

import configparser
from datetime import timedelta

from fastapi import APIRouter, HTTPException, Request

import telegram
from telegram import Update, Bot
from telegram.ext import Dispatcher, MessageHandler, CallbackQueryHandler, Filters, CallbackContext

from process import MsgEvent, processor


# 建議將Token存入環境變數檔或者資料庫中
config = configparser.ConfigParser()
config.read('config.ini')

# 一樣需要用FastApi的Router功能初始化telegram_api
telegram_api = APIRouter()

# 還記得上面說的Token嗎?要寫在這邊
bot = telegram.Bot(token=(config['TELEGRAM']['ACCESS_TOKEN']))

# 這邊一樣,將 /api/telegram/hook 註冊於 rounter中
@telegram_api.post('/api/telegram/hook')
async def webhook_handler(request: Request):
    """Set route /hook with POST method will trigger this method."""
    body = await request.json()

    update = telegram.Update.de_json(body, bot)

    # 需要將來的事件丟進handler
    dispatcher.process_update(update)
    return 'ok'


def handle_message(update: Update, context: CallbackContext):
    """Reply message."""
    text = update.message.text
    user_id = update.message.chat.id
    user_type = update.message.chat.type

    send_time = update.message.date + timedelta(hours=8)

    # 處理的部分
    msg_event = MsgEvent("telegram", user_type, user_id, send_time, "text", text)
    response = msg_processor.analyze(msg_event)

    # 這邊示範寄出照片跟文字訊息
    bot.send_photo(update.message.chat_id, photo=response.photo)
    bot.send_message(update.message.chat_id, response.text)


def handle_callback(update: Update, context: CallbackContext):
    """Reply message."""
    data = update.callback_query.data
    user_id = update.callback_query.message.chat.id
    user_type = update.callback_query.message.chat.type

    send_time = update.callback_query.message.date + timedelta(hours=8)

    # 處理的部分,請各位自由發揮
    msg_event = MsgEvent("telegram", user_type, user_id, send_time, "option", data)
    response = msg_processor.analyze(msg_event)

    # 這邊示範寄出照片跟文字訊息
    bot.send_photo(update.callback_query.message.chat_id, photo=response.photo)
    bot.send_message(update.callback_query.message.chat_id, response.text)


# New a dispatcher for bot
dispatcher = Dispatcher(bot, None)

# 需要新增 Handler 來處理特定的訊息跟動作
dispatcher.add_handler(MessageHandler(Filters.text, handle_message))
dispatcher.add_handler(CallbackQueryHandler(handle_callback))

跟上方的 Line Api 實作方式很相似,幾個注意的要點,一樣提醒大家

  1. Telegram token 建議使用 config, .env檔案儲存或者存入資料庫

記得利用 token 初始化一個 telegram 的 bot,會需要使用 bot 做回覆訊息的功能

# 還記得上面說的Token嗎?要寫在這邊,初始化你的Telegram Bot
bot = telegram.Bot(token=(config['TELEGRAM']['ACCESS_TOKEN']))
  1. 記得要新增你想處理的訊息/動作的 Handler
# 需要新增 Handler 來處理特定的訊息跟動作
dispatcher.add_handler(MessageHandler(Filters.text, handle_message))
dispatcher.add_handler(CallbackQueryHandler(handle_callback))

以這個例子來說是處理 訊息動作文字訊息Callback動作

  • MessageHandler(Filters.text, handle_message)
  • CallbackQueryHandler(handle_callback)

所以假設你要處理客戶端傳來音訊,那就是以此類推:

  • MessageHandler(Filters.audio, handle_message)

詳細情況再麻煩爬文 Python 的 Telegram 套件

  1. 記得要將 Telegram 的 api route 透過 FastApi APIRouter 加入路徑
telegram_api = APIRouter()

@telegram_api.post('/api/telegram/hook')
async def webhook_handler(request: Request):
    pass

  1. 注意由一般訊息來的參數跟從 callback 動作來的參數會在不同地方
# 以取user_id為例

# 一般訊息從update.message來
user_id = update.message.chat.id

# Callback 的參數會在callback_query內
user_id = update.callback_query.message.chat.id

  1. 寄出去的訊息型別

在 Telegram 中傳送文字訊息跟圖片不用特別轉換成另外一種訊息物件,寄圖片有寄圖片的函式send_photo,重送訊息也有傳送訊息的函式 send_message,只是參數中無論是 photo 還是 text 都要塞字串。

bot.send_photo(update.message.chat_id, photo=response.photo)
bot.send_message(update.message.chat_id, response.text)

再來,我用的按鈕訊息,也是用send_message來發送訊息,唯一不同的是,需要多帶一個參數


# 你需要用`InlineKeyboardMarkup`跟`InlineKeyboardButton`兩個物件做出你要的訊息模式,再帶入reply_markup中,所以資訊會長這樣

markup =
InlineKeyboardMarkup([
    InlineKeyboardButton("button1", "{'action': push}"),
    InlineKeyboardButton("button2", "{'action': pull}"),
    InlineKeyboardButton("button3", "{'action': cut}")
])

bot.send_message(chat_id, text, reply_markup=markup)

換你實作啦!

Telegram Bot 的一些注意細節

  • Request 若沒得到回應,會一直重複對 api 發送

這點和 Line 不同,Telegram 的 Request 沒有短時效問題,所以如果 Request 沒有得到回覆(也就是可能在跑程式的某個地方報錯),那那個 Request 會一直循環在網路雲端中,然後不斷地往 api 發送直到取得答案為止,我有點忘記時效多少,但我記得好像...一天內失敗的 Request 都會活著。

如果不幸在測試程式時,發送 Request 太多失敗,那就 Debug 模式繼續開著等剛剛發送的 Request 會延遲幾秒後再次自動向 api 發送。

  • CallBack Data 參數有限制

如果要用 InlineKeyboardButton當作按鈕做出 Callback 行為,要注意一下 callback_data不能超過 64 的字,由於我是塞字典,所以一下就爆字數了,文件在此 InlineKeyboardButton

我的參數:

# 隨隨便便寫的字典就48個字了
InlineKeyboardButton(callback_data=str({"category": "communication", "action": "flirt"}))

當然你也可以直接就寫文字,例如:

# 寫中文字完全OK
InlineKeyboardButton(callback_data=str("會話,調情"))

看個人發揮,我個人是塞字典,比較好我後端程式後續的程式操作,但又很容易爆字!

所以我的一個作法是,我在 CallBack 訊息傳出去跟傳回來時做了一個壓縮器/加密器,將超過長度的 callback_data透過演算法或加密法壓縮成 64 個字內,回來時再用一樣的方式解密,這樣就能夠解決 64 個字限制的問題!

  • InlineKeyboardButton 按鈕寬度跟排列

在實作 Button 訊息時,老實說我也有遇到如果我的按鈕字數太長,他就會被遮住,比方說:

# 例如這樣字就會被壓縮到!

[Runes][Tarot][Leno..][Moon..]

可是我希望字可以全部顯示,希望能夠將按鈕加寬。Telegram 沒能讓按鈕加寬,但可以透過按鈕的排列來使按鈕加寬,這邊給些例子參考

# 假設完整是這樣
markup =
InlineKeyboardMarkup([
    InlineKeyboardButton("button1", "{'action': push}"),
    InlineKeyboardButton("button2", "{'action': pull}"),
    InlineKeyboardButton("button3", "{'action': cut}")
])

# 我們將一些字簡略,比較好展示

Markup(
    [Button(), Button()]
)


# 我們將一些字簡略,比較好展示

Markup(
    [
        [Button()],
        [Button()],
        [Button()],
        [Button()],
        [Button()],
        [Button()]
        ]
)


# 我們將一些字簡略,比較好展示

Markup(
    [
        [Button()],
        [Button(), Button()],
        [Button(), Button()],
        ]
)

簡單來說就是用 list 來去分割按鈕

選單 Menu

再來也是 Telegram 的選單,Telegram 的選單基本上沒有像 Line 那樣的華麗,如圖,這是 Miri 的選單

蠻乾淨簡單的,是用 /command的動作作為選單按鈕,然後最一開始 Menu 會向上方圖一樣,在文字輸入匡左方會多一個 Menu,然後可以縮放跟展開。

Menu 的設定也是呼叫 機器人的爸爸 XD,BotFather,來設定選單

在 Telegram 中不是 Set Menu,而是setcommands

格式如下:

command1 - Description
command2 - Another description

你可以一次把要設定的 command 打齊一次送出去!

就有美美簡約的選單囉!

困難克服: 訊息於不同平台的呈現方式

在實作於 Line Bot 跟 Telegram Bot 遇到的一個困難是,由於這兩個後端是共用程式,所以期望上希望兩者呈現的功能要一致,簡單來說就是當點下占卜按鈕時,要跳出六個占卜方法,我希望是圖文並茂,這在 Line Bot 上,用Carousel message可以完美辦到:

但是在 Telegram 就不是這麼一回事了,因為 Telegram 沒有 Carousel message,所以在這個單元想來介紹一下,我是怎麼改動流程盡量讓兩邊平台訊息一致的。

首先是選單目錄,在 Line 使用Carousel message,在 Telegram 用InlineKeyboardMarkup

可以往右滑

在 Telegram 的呈現上會稍微遜色,畢竟沒有圖片輔佐

所以在規劃使用者流程時,我會特地讓 Line Carousel message變成像是列表的邏輯,當點進去單個項目,才是進到那個項目的動作,來展現一下

點選Click後,兩邊訊息會一致

Line 點選後會進到 Button Message,用一個感覺多此一舉的步驟來緩和 Telegram 沒有辦法圖文兼具的窘境。

接著我們來看一下 Telegram 方,使用了 Photo + InlineKeyboardMarkup 兩個訊息的 Combo 連發達成像 Line 的效果,緩和解決了沒有圖片的問題。

由於本人算是第一次做這種前端有兩個以上的平台,先前都是後端對到前端,我相信很多厲害的 app 或網站,兩者兼具的軟體都需要具備這種面對兩種平台以上的後端程式設計,而這是我第一次面對,所以難免在處理上會比較尷尬一點。

也許這也就能夠理解為什麼有些軟體有些功能在網頁上是沒有的,但是在 app 上有,也許就是因為沒辦法做到兩邊展現同樣的效果跟體驗,所以必須有一方是犧牲的。

這是我目前短暫想出的方式,也許會讓 Line 使用者在使用上有點多此一舉跟卡卡的感覺。期望未來如果我實作前端網頁跟 mobile 版本後,就不需要糾結於這個問題,但也有可能未來即便出了網頁跟手機端,還是會保留機器人版本(說不定喔!)

好啦,這篇應該是產品技術文章中最長的文章,能看到這邊的人也蠻厲害的了(還是只會有我看得到 XD)

多謝支持啦!