Nắm tình hình khói bụi: tạo BOT chất lượng không khí cùng Haskell

Việc tiếp xúc với ô nhiễm không khí trong một thời gian ngắn hay dài hạn đều có thể mang lại các vấn đề lớn về sức khoẻ. Khi nhận thấy chất lượng không khí không đảm bảo, chúng ta nên tránh một số hoạt động nhất định. Điều này khiến đặt ra câu hỏi: Làm thế nào để có được thông báo khi nào chất lượng không khí không tốt? Bài viết này là một nỗ lực giải quyết vấn đề đó.

Trong bài viết này, tôi muốn chỉ cho các bạn một chatbot giúp chúng ta giảm thiểu việc hít thở không khí ô nhiễm như thế nào, đồng thời hướng dẫn các bạn làm thế nào để tạo ra chatbot dạng đó với Haskell và sử dụng line-bot-sdk. Hướng dẫn này đang coi như bạn đã một phần nào đó quen thuộc với việc sử dụng Haskell.

Ý tưởng của tôi về chatbot này rất đơn giản: người dùng chia sẻ vị trí, và bot đọc các dữ liệu ô nhiễm được công khai từ các trạm theo dõi tại các khu vực bản địa. Nếu không khí không tốt cho sức khoẻ, chúng ta đẩy một tin nhắn thông báo đến người dùng. 

Vì sao ta sử dụng chatbot?

Chatbot giúp người dùng có được sự tương tác mượt mà với service của bạn, và khá dễ dàng tích hợp được chatbot với các ứng dụng nhắn tin. Việc sử dụng chatbot thêm vào đó cũng có có lợi trong việc không chỉ người dùng, mà các nhóm người dùng cũng có được sự tương tác. Ví dụ, trong bài viết hướng dẫn này, bạn sẽ học được cách làm thế nào thả được một con bot vào trong nhóm chat gia đình của mình, và sau đó bạn và người thân sẽ nhanh chóng được thông báo khi nào chất lượng không khí không tốt tại các khu vực mà bạn quan tâm. 

line-bot-sdk

Chúng ta sẽ sử dụng line-bot-sdk, một Haskell SDK dành cho platform nhắn tin LINE. Bạn có thể tìm thấy tổng quan của LINE Messaging API tại đây. Bài viết này generate từ các source literate của Haskell. Bạn đọc nào thích đọc code hơn, thì có thể tìm thấy bản extract tại đây.

line-bot-sdk sử dụng servant framework, một set các package mà declare các web API theo type level:

import Servant

Chúng ta sẽ sử dụng một số GHC extension trong suốt bài hướng dẫn này, ví dụ như -XOverloadedStrings-XRecordWildCards cùng một vài loại khác trong quá trình thực hiện. 

Parse dữ liệu đo lường

Cơ quan bảo vệ môi trường của Đài Loan theo dõi ô nhiễm không khí tại các thành phố lớn và khu hành chính khắp Đài Loan. Cơ quan này có công khai API cung cấp thông tin ô nhiễm không khí cập nhật nhất mà bạn nhìn thấy như dưới đây:

curl https://opendata.epa.gov.tw/ws/Data/AQI/?\$format=json | jq .
[
 {
  "SiteName": "基隆",
  "County": "基隆市",
  "AQI": "40",
  "Pollutant": "",
  "Status": "良好",
  "SO2": "3.7",
  "CO": "0.24",
  "CO_8hr": "0.2",
  "O3": "44",
  "O3_8hr": "43",
  "PM10": "25",
  "PM2.5": "10",
  "NO2": "",
  "NOx": "",
  "NO": "",
  "WindSpeed": "1.1",
  "WindDirec": "90",
  "PublishTime": "2019-04-29 16:00",
  "PM2.5_AVG": "12",
  "PM10_AVG": "27",
  "SO2_AVG": "2",
  "Longitude": "121.760056",
  "Latitude": "25.129167"
 }
]

API này trả về một dãy JSON với số liệu đo đạc tại các trạm theo dõi tại Đài Loan, thông thường cập nhật hàng giờ. Một số AQI dưới 100 có nghĩa là chất lượng không khí tốt hoặt chấp nhận được, mặt khác con số trên 100 sẽ mang đến nhiều quan ngại. Trong các chất gây ô nhiễm được đo đạc báo cáo, có chất hạt, tầng ozone mặt đất, carbon monoxide và sulfur dioxide.

Chúng ta sẽ cần thêm một địa điểm đo đạc nữa, đây là nơi ta sẽ sử dụng để tìm ra thông số gần nhất có thể lấy được để đưa cho người dùng:

import Data.Text (Text)

data AQData = AQData
  { aqi :: Int
  , county :: Text
  , lat :: Double
  , lng :: Double
  , status :: Text
  , pm25 :: Int
  , pm10 :: Int
  , o3 :: Int
  , co :: Double
  , so2 :: Double
  }
  deriving (Eq, Show)

Tuy nhiên, đầu tiên, ta cần phải process dữ liệu (data) trước đã. Hãy lưu ý rằng các trường JSON đều ở dạng string, nhưng AQData type của chúng ta thì lại yêu cầu giá trị bằng số, và có một vài điểm số liệu bị thiếu thông tin cụ thể, ví dụ như AQI hay địa điểm: 

{
 "SiteName": "彰化(大城)",
 "County": "彰化縣",
 "AQI": "",
 "Pollutant": "",
 ...
}

Do vậy, chúng ta sẽ cần phải gỡ các data point đó ra, bởi chúng gây nhiễu số liệu. Một cách khả thi khác, là chúng ta gói [AQData] trong một newtype:

newtype AQDataResult = AQDataResult { result :: [AQData] }

Sau đó ta cung cấp một instance của class FromJSON để decode và lọc các bad value

instance FromJSON AQDataResult where
  parseJSON = undefined

Tuy nhiên vẫn còn có một cách khác. FromJSON còn có một phương kháp khác mà parse được một giá trị JSON vào một dãy value:

parseJSONList :: Value -> Parser [a]
import Data.Aeson
import Control.Monad (forM)
import qualified Data.Vector as V (toList)
import Data.Maybe (catMaybes)

instance FromJSON AQData where
 parseJSONList = withArray "[AQData]" $ \arr ->
   catMaybes <$> forM (V.toList arr) parseAQData
 parseJSON _ = fail "not an array"

Các item array sẽ được đi qua parseAQData. Tại đây, MaybeT monad transformer sẽ chỉ sản sinh ra giá trị khi tất cả các item có mặt đầy đủ:

import Data.Aeson.Types
import Control.Monad.Trans.Maybe (runMaybeT, MaybeT(..))
import Text.Read (readMaybe)
import Control.Monad.Trans.Class (lift)

parseAQData :: Value -> Parser (Maybe AQData)
parseAQData = withObject "AQData" $ \o -> runMaybeT $ do
  aqi    <- MaybeT $ readMaybe <$> o .: "AQI"
  county <- lift   $               o .: "County"
  lat    <- MaybeT $ readMaybe <$> o .: "Latitude"
  lng    <- MaybeT $ readMaybe <$> o .: "Longitude"
  status <- lift   $               o .: "Status"
  pm25   <- MaybeT $ readMaybe <$> o .: "PM2.5"
  pm10   <- MaybeT $ readMaybe <$> o .: "PM10"
  o3     <- MaybeT $ readMaybe <$> o .: "O3"
  co     <- MaybeT $ readMaybe <$> o .: "CO"
  so2    <- MaybeT $ readMaybe <$> o .: "SO2"
  return AQData {..}

Sau đó ta dùng hàm catMaybes :: [Maybe a] -> [a] để loại bỏ Nothings và trả về một danh sách AQData. Lúc này bởi ta có một instance FromJSON, ta có thể viết được một hàm client để gọi API này:

import Control.Exception (try)
import Network.HTTP.Simple (httpJSON, HttpException, getResponseBody)

getAQData :: IO [AQData]
getAQData = do
  eresponse <- try $ httpJSON opendata
  case eresponse of
    Left e -> do
      print (e :: HttpException)
      getAQData -- retry
    Right response -> return $ getResponseBody response
  where
    opendata = "https://opendata.epa.gov.tw/ws/Data/AQI?$format=json"

Tại đây ta chỉ có thể intercept các exception của type HTTPException. Để đơn giản, ta chỉ retry khi các request fail, thông thường bạn nên kiểm tra lỗi và thực hiện các retry với exponential backoff.

Khoảng cách giữa hai địa điểm 

Chúng ta muốn bot thông báo cho người dùng chất lượng không khí không tốt tại khu vực họ sống và làm việc, bởi vậy đầu tiên chúng ta cần biết trạm theo dõi nào gần với họ nhất. Để làm được điều đó, ta sử dụng công thức harvesine để xác định khoảng cách cung vòng lớn giữa hai điểm sử dụng kinh độ và vĩ độ:

Đầu tiên ta định nghĩa alias type cho cặp kinh độ và vĩ độ (đơn vị độ) 

type Coord = (Double, Double)

Với distance, ta có thể tính toán khoảng cách theo km giữa hai điểm vị trí:

import Data.Bifunctor (bimap)

distRad :: Double -> Coord -> Coord -> Double
distRad radius (lat1, lng1) (lat2, lng2) = 2 * radius * asin (min 1.0 root)
 where
   hlat = hsin (lat2 - lat1)
   hlng = hsin (lng2 - lng1)
   root = sqrt (hlat + cos lat1 * cos lat2 * hlng)
   hsin = (^ 2) . sin . (/ 2) -- harvesine of an angle

distDeg :: Double -> Coord -> Coord -> Double
distDeg radius p1 p2 = distRad radius (deg2rad p1) (deg2rad p2)
 where
   d2r = (/ 180) . (* pi)
   deg2rad = bimap d2r d2r

distance :: Coord -> Coord -> Double
distance = distDeg 6371 -- mean earth radius

Khi đã có hàm tính toán khoảng cách giữa hai vị trí, việc còn lại duy nhất ta phải làm là tìm ra được điểm đo đạc gần nhất (ví dụ: điểm đo đạc gần vị trí người dùng nhất):

import Data.List.Extra (minimumOn)

getCoord :: AQData -> Coord
getCoord AQData{..} = (lat, lng)

closestTo :: [AQData] -> Coord -> AQData
closestTo xs coord = (distance coord . getCoord) `minimumOn` xs

minimumOn :: Ord b => (a -> b) -> [a] -> a được định nghĩa trong package extra.

Môi trường ứng dụng

Hầu hết các sự tính toán mà chúng ta thực hiện cần việc đọc giá trị trong một môi trường được chia sẻ:

import Line.Bot.Webhook.Events (Source)
import Line.Bot.Types (ChannelToken, ChannelSecret)
import Control.Concurrent.STM.TVar (TVar)

data Env = Env
  { token  :: ChannelToken
  , secret :: ChannelSecret
  , users  :: TVar [(Source, Coord)]
  }

Bằng cách này, ta có thể truyền chung được channel token, mà cần phải có để gọi LINE platform,  và channel secret để validate các webhook event. Đồng thời ta cũng cần có danh sách người dùng mà được thể hiện dưới dạng (Source, Coord). Source được định nghĩa trong Line.Bot.Webhook.Events và chứa ID của người dùng, nhóm người dùng hoặc room chat mà sẽ được push tin nhắn. Danh sách người dùng sẽ được đọc và cập nhật theo phương pháp đồng thời từ các thread khác nhau, do vậy chúng ta lưu lại dưới dạng biến thay đổi được, sử dụng Control.Concurrent.STM.TVar từ stm package [1]

Chúng ta sẽ sử dụng class dạng mtl thay vì một monad transformer stack trong bài hướng dẫn này [2], với các hàm polymorphic trong effect type. Lợi ích của việc tiếp cận theo phương pháp này là type rõ ràng ràng buộc expresss (và enforce) giúp effect xảy ra, và cái lợi bổ sung là nó cung cấp cho ta thêm nhiều option cho composition.

import Control.Monad.IO.Class (MonadIO, liftIO)
import Control.Monad.Reader (MonadReader, ask)

Ta cũng sẽ cần sử dụng  -XFlexibleContexts extension để cho phép các type khác ngoài các type ở trong constraint type. 

Xử lý webhook event

Khi một event được trigger, ví dụ như khi bot join một chatroom, một HTTP POST request gồm thông channel sẽ được gửi tới webhook URL đã được đăng ký. Tại đây chúng ta quan tâm đến 3 loại event (các event khác sẽ bị loại bỏ): 

  • khi bot được thêm vào danh sách bạn bè dưới dạng add friend (hoặc unblock)  
  • khi bot tham gia một nhóm chat hoặc một phòng chat
  • khi bot nhận được một tin nhắn về địa điểm từ người dùng 
{-# LANGUAGE LambdaCase #-}


import Line.Bot.Webhook.Events (Event (EventFollow, EventJoin, EventMessage),
                                Message (MessageLocation))

webhook :: (MonadReader Env m, MonadIO m) => [Event] -> m NoContent
webhook events = do
  forM_ events $ \case
    EventFollow  {..} -> askLoc replyToken
    EventJoin    {..} -> askLoc replyToken
    EventMessage { message = MessageLocation {..}
                 , source = source }    
                      -> addUser source (latitude, longitude)
    _                 -> return ()
  return NoContent

Đối với hai mục đầu tiên, chúng ta trả lời bằng một đoạn tin nhắn dạng text chứa button Quick reply (trả lời nhanh) trong đó kèm một action liên quan đến vị trí: điều này cho phép người dùng dễ dàng chia sẻ vị trí nhằm giúp theo dõi chất lượng không khí.

Chúng ta sử dụng Line Bot Line.Bot.Types.ReplyToken, bot này bao gồm các event được trả lời:

replyMessage :: ReplyToken -> [Message] -> Line NoContent

LiLine là monad để gửi request đến LINE platform.

import Line.Bot.Client (replyMessage, runLine)
import Line.Bot.Types (ActionLocation, MessageText, QuickReply,
                       QuickReplyButton, ReplyToken)

askLoc :: (MonadReader Env m, MonadIO m) => ReplyToken -> m ()
askLoc rt = do
  Env {token} <- ask
  _ <- liftIO $ runLine comp token
  return ()
  where
    welcome = "Where are you?"
    qr = QuickReply [QuickReplyButton Nothing (ActionLocation "location")]
    comp = replyMessage rt [MessageText welcome (Just qr)]

MessageText là data constructor từ Line.Bot.Types.Message. Toàn bộ các message có thể gửi đi bởi QuickReply dạng optional; Các quick reply cho phép người dùng lựa chọn trong các tin nhắn trả lời khả thi, tham khảo thêm thông tin cụ thể về quick reply tại đây.

Cuối cùng runLine chạy request đã được đưa ra với channel token từ môi trường số [3]:

runLine :: Line a -> ChannelToken -> IO (Either ClientError a)

Một khi chúng ta đã nhận event về tin nhắn có vị trí, chúng ta thêm người dùng và vị trí của họ vào danh sách người dùng được chia sẻ:

import Control.Concurrent.STM.TVar (modifyTVar)
import Control.Monad.STM (atomically)
import Line.Bot.Webhook.Events (Source)

addUser :: (MonadReader Env m, MonadIO m) => Source -> Coord -> m ()
addUser source coord = do
  Env {users} <- ask
  liftIO $ atomically $ modifyTVar users ((source, coord) : )
  return ()

Chúng ta thêm source của event vào, do đó nếu message được gửi từ một group, chúng ta sẽ thông báo cho toàn group, không phải chỉ người dùng đã chia sẻ vị trí.

Lưu ý là trong thực tế, bạn nên xử lý dual event bằng  unfollow hay leave.

Phục vụ webhook: ứng dụng WAI  

Để phục vụ webhook API, chúng ta cần produce WAI app.

line-bot-sdk export một loại sysonym được định nghĩa tại  Line.Bot.Webhook, trong đó có encode API webhook của LINE:

newtype Events :: Events { events :: [Event] }
type Webhook = LineReqBody '[JSON] Events :> Post '[JSON] NoContent

Combinator LineReqBody sẽ kiểm tra các request nhận được có đúng bắt nguồn từ LINE platform hay không.

aqServer :: ServerT Webhook (ReaderT Env Handler)
aqServer = webhook . events

Các servant handler sẽ default chạy trong monad Handler. Để webhook handler của chúng ta đọc được môi trường Env (được thực thi bởi type constraint trong webhook), chúng ta sẽ stack monad Reader.

Việc này nằm ngoài scope của nội dung hướng dẫn nhằm cover được các chi tiết thực hành cơ bản Servant web framework, những thứ mà thật ra đã được hướng dẫn sẵn rồi (tham khảo hướng dẫn servant ).

{-# LANGUAGE DataKinds #-}

import Servant.Server (Context ((:.), EmptyContext))

api = Proxy :: Proxy Webhook
ctx = Proxy :: Proxy '[ChannelSecret]

app :: MonadReader Env m => m Application
app = ask >>= \env ->
  let server = hoistServerWithContext api ctx (`runReaderT` env) aqServer
  in return $ serveWithContext api (secret env :. EmptyContext) server

Bước cuối cùng là biến aqServer của chúng ta thành WAI Application.

SerServant cho phép pass các giá trị tới các combinator bằng cách sử dụng Context. Combinator LineReqBody cần một Context với channel secret. Điều này được thực thi bởi list type-level '[ChannelSecret].

Cập nhật định kỳ

Chúng ta đã định nghĩa getAQData, một IO action mà trả về một list của các điểm data (valid). Mục tiêu của chúng ta bây giờ là gọi API này mỗi giờ để lấy thông tin đo đạc mới nhất và map với người dùng, dựa trên vị trí:

import Line.Bot.Client (runLine)
import Control.Monad (forM_)
import Control.Concurrent.STM.TVar (readTVar)
import Control.Monad.STM (atomically, retry)

processAQData :: (MonadReader Env m, MonadIO m) => m ()
processAQData = do
  Env {users, token} <- ask
  users' <- liftIO $ atomically $ do
    xs <- readTVar users
    case xs of
      [] -> retry
      _ -> return xs
  liftIO $ getAQData >>= \aqData ->
    let users'' = [(user, aqData `closestTo` coord) | (user, coord) <- users']
    in forM_ users'' $ flip runLine token . notifyChat

processAQData làm được một vài thứ [4]

  • Đọc danh sách lưu trong biến variable users trong môi trường: nếu danh sách rỗng, retry, blockin thread cho tới khi có người dùng được thêm mới;
  • Gọi getAQData để lấy thông số chất lượng không khí gần đây nhất;
  • Sau đó ta chạy một list comprehention để map người dùng của type (Source, Coord) tới (Source, AQData)
  • Đối với từng người dùng, gọi notifyChat
import Line.Bot.Client (Line, pushMessage)
import Line.Bot.Webhook.Events (Source)

notifyChat :: (Source, AQData) -> Line NoContent
notifyChat (Source a, x)
  | unhealthy x = pushMessage a [mkMessage x]
  | otherwise = return NoContent

Để cảnh báo cho người dùng, chúng ta push tin nhắn tới những người dùng đang ở gần với trạm theo dõi nhất mà có báo cáo chỉ số AQI vượt 100:

unhealthy :: AQData -> Bool
unhealthy AQData{..} = aqi > 100

processAQData cần được gọi định kỳ, ít nhất là mỗi giờ. Chúng ta sẽ chạy theo nhiều thread khác nhau, để đảm bảo nó có thể được gọi liên tục với server webhook của chúng ta:

import Control.Concurrent.Lifted (fork, threadDelay)
import Control.Monad (forever)
import Control.Monad.Trans.Control (MonadBaseControl)

loop :: (MonadReader Env m, MonadIO m, MonadBaseControl IO m) => m ()
loop = do
  fork $ forever $ do
    processAQData
    threadDelay (3600 * 10^6)
    return ()

Cảnh báo chất lượng không khí

Để thông báo cho người dùng mức độ ô nhiễm, ta sử dụng Flex Message, đó là các tin nhắn với layout thiết kế đa dạng được viết đưới dạng format JSON.

LINE chat
import Line.Bot.Types (Message (MessageFlex))

mkMessage :: AQData -> Message
mkMessage x = MessageFlex "air quality alert!" (flexContent x) Nothing 

Một Flex message sẽ được soạn bởi bởi một loại tin nhắn khác (dành cho giao diện không hỗ trợ tính năng này), một Data.Aeson.Value trong đó bao gồm layout tin nhắn và nội dung tin nhắn, và một gợi ý trả lời nhanh:

MessageFlex :: Text -> Value -> Maybe QuickReply -> Message

Để thiết kế layout của tin nhắn cảnh báo, chúng tôi đã sử dụng Flex Message Simulator. Chúng ta sẽ dùng JSON quasiquoter aesonQQ, có vai trò chuyển đổi (trong giai đoạn compile time) một string repsentation của giá trị JSON thành Value :

{-# LANGUAGE QuasiQuotes #-}

import Data.Aeson.QQ (aesonQQ)
import Data.Aeson.Types

flexContent :: AQData -> Value
flexContent AQData{..} = [aesonQQ|
  {
    "type": "bubble",
    "styles": {
      "footer": {
        "separator": true
      }
    },
    "header": {
      "type": "box",
      "layout": "vertical",
      "contents": [
        {
          "type": "text",
          "text": "AIR QUALITY ALERT",
          "weight": "bold",
          "size": "xl",
          "color": "#ff0000",
          "margin": "md"
        },
        {
          "type": "text",
          "text": "Unhealthy air reported in your area",
          "size": "xs",
          "color": "#aaaaaa",
          "wrap": true
        }
      ]
    },
    "body": {
      "type": "box",
      "layout": "vertical",
      "contents": [
        {
          "type": "box",
          "layout": "vertical",
          "margin": "xxl",
          "spacing": "sm",
          "contents": [
            {
              "type": "box",
              "layout": "horizontal",
              "contents": [
                {
                  "type": "text",
                  "text": "County",
                  "size": "sm",
                  "color": "#555555",
                  "flex": 0
                },
                {
                  "type": "text",
                  "text": #{county},
                  "size": "sm",
                  "color": "#111111",
                  "align": "end"
                }
              ]
            },
            {
              "type": "box",
              "layout": "horizontal",
              "contents": [
                {
                  "type": "text",
                  "text": "Status",
                  "size": "sm",
                  "color": "#555555",
                  "flex": 0
                },
                {
                  "type": "text",
                  "text": #{status},
                  "size": "sm",
                  "color": "#111111",
                  "align": "end"
                }
              ]
            },
            {
              "type": "box",
              "layout": "horizontal",
              "contents": [
                {
                  "type": "text",
                  "text": "AQI",
                  "weight": "bold",
                  "size": "sm",
                  "color": "#ff0000",
                  "flex": 0
                },
                {
                  "type": "text",
                  "text": #{show aqi},
                  "weight": "bold",
                  "size": "sm",
                  "color": "#ff0000",
                  "align": "end"
                }
              ]
            },
            {
              "type": "box",
              "layout": "horizontal",
              "contents": [
                {
                  "type": "text",
                  "text": "PM2.5",
                  "size": "sm",
                  "color": "#555555",
                  "flex": 0
                },
                {
                  "type": "text",
                  "text": #{show pm25},
                  "size": "sm",
                  "color": "#111111",
                  "align": "end"
                }
              ]
            },
            {
              "type": "box",
              "layout": "horizontal",
              "contents": [
                {
                  "type": "text",
                  "text": "PM10",
                  "size": "sm",
                  "color": "#555555",
                  "flex": 0
                },
                {
                  "type": "text",
                  "text": #{show pm10},
                  "size": "sm",
                  "color": "#111111",
                  "align": "end"
                }
              ]
            },
            {
              "type": "box",
              "layout": "horizontal",
              "contents": [
                {
                  "type": "text",
                  "text": "O3",
                  "size": "sm",
                  "color": "#555555",
                  "flex": 0
                },
                {
                  "type": "text",
                  "text": #{show o3},
                  "size": "sm",
                  "color": "#111111",
                  "align": "end"
                }
              ]
            },
            {
              "type": "box",
              "layout": "horizontal",
              "contents": [
                {
                  "type": "text",
                  "text": "CO",
                  "size": "sm",
                  "color": "#555555",
                  "flex": 0
                },
                {
                  "type": "text",
                  "text": #{show co},
                  "size": "sm",
                  "color": "#111111",
                  "align": "end"
                }
              ]
            },
            {
              "type": "box",
              "layout": "horizontal",
              "contents": [
                {
                  "type": "text",
                  "text": "SO2",
                  "size": "sm",
                  "color": "#555555",
                  "flex": 0
                },
                {
                  "type": "text",
                  "text": #{show so2},
                  "size": "sm",
                  "color": "#111111",
                  "align": "end"
                }
              ]
            }
          ]
        }
      ]
    },
    "footer": {
      "type": "box",
      "layout": "horizontal",
      "contents": [
        {
          "type": "button",
          "action": {
            "type": "uri",
            "label": "More info",
            "uri": "https://www.epa.gov.tw/"
          }
        }
      ]
    }
  }
|]

Tổng hợp lại

Vậy là ta đã gần xong! Còn thứ cuối cùng cần phải chạy nữa thôi là server và main loop: 

  • Ta lấy channel token và sectere read từ môi trường
  • tạo một Env initial
  • Thực hiện thread inital environment vào app và loop của chúng ta
  • Gọi Network.Wai.Handler.Warp.run để chạy webhook ở cổng 3000
import System.Environment (getEnv)
import Control.Concurrent.STM.TVar (newTVar)
import Control.Monad.Reader (runReader, runReaderT)
import Network.Wai.Handler.Warp (run)
import Data.String (fromString)

main :: IO ()
main = do
  token <- fromString <$> getEnv "CHANNEL_TOKEN"
  secret <- fromString <$> getEnv "CHANNEL_SECRET"
  env <- atomically $ Env token secret <$> newTVar []
  runReaderT loop env
  run 3000 $ runReader app env

Tại đây, bạn có thể thật sự nhìn thấy chúng tôi khởi tạo  loop and app thành các monad cụ thể.

Bạn muốn kết bạn với BOT?

Nếu bạn đang sinh sống tại Đài Loan, và muốn được thông báo về chất lượng không khí, follow bot này nhé:

Bot này giống với con BOT mà tôi đã giải thích trong bài viết, chỉ khác là nó sử dụng PostGIS thay vì biến transactional để lưu các user.

Bạn có thể tìm thấy code repository ở đây: https://github.com/moleike/taiwan-aqi-bot

Kết luận

Trong bài hướng dẫn này, chúng ta đã đi hết được việc làm thế nào để phát triển một bot đơn giản nhưng có ích. Tôi hi vọng bạn thấy bài viết thú vị (hoặc có thể code cùng), và tôi hi vọng thậm chí đã mang cho bạn cảm hứng phát triển chatbot riêng theo ý tưởng của mình!


[1] Lưu ý là trong ví dụ này, danh sách các user không tiếp tục tồn tại khi restart hay crash; Trong môi trường production bạn nên sử dụng database để lưu user.

[2] Lập trình mtl-style

[3] Để bài hướng dẫn này được ngắn gọn xúc tích, chúng ta không kiểm tra các lỗi có thể xảy ra, nhưng bạn nên pattern match kết quả của runLineLine có một instance là MonadError ClientError mà bạn cũng có thể giúp phát hiện được các lỗi

[4] Giải pháp giản đơn mà chúng ta xây dựng lên ở đây để liên kết người dùng với các trạm theo dõi, có thể không thực tế trong việc áp dụng thực tiễn, trong đó sẽ hợp lý hơn nếu lọc ra các điểm data mà tình hình ô nhiễm được quan tâm, từ đó đối với mỗi một điểm data, lấy thông tin toàn bộ các user nằm trong một khoảng cách nhất định (ví dụ sử dụng geospatial index), và thông báo cho họ bằng các multicast message.