やらなイカ?

たぶん、iOS/Androidアプリの開発・テスト関係。

調整さんリマインダLINE BOTを作ってみた

調整さん」を(日程調整ではなく)出欠の管理に使っている前提で、出欠登録のリマインダをLINE BOTとして作ってみたので、そのメモ。

[9/29追記]LINE BOT APIはDeprecatedとなり、Messaging APIに置き換わりました。同時にSDKもMessaging API対応のものに更新されていますので、ご注意ください。

[10/23追記]LINE BOT API Trial Accountは、11/16に完全削除を予定されていると発表されました。

新しいMessaging APIへの置き換えについては、次のエントリを参照してください。

nowsprinting.hatenablog.com

環境

仕様

  • 調整さんのイベントをクロールし、3日後に開催される予定がある場合、参加人数などを通知する
  • 通知は、BOTと友だち登録した人全員に送られる(cronで毎朝8:00に起動)
    • LINEグループに参加させたかったが、BOTはLINEグループに参加させることはできない*1
    • BOTの友だち登録は、デフォルトでは50人まで
    • BOTはid検索できないため、友だち登録は次の手順で行なう:QRコード画像を送る→画像を端末に保存→LINEで[友だち追加]→[QRコード]→[ライブラリ]→保存したQRコード画像を選択
  • 監視対象の調整さんイベントは、ハッシュをapp.yamlに記述しておく
  • LINE BOTのシークレットキーなどもapp.yamlに記述

GAE/Goのプロジェクト作成

Google Cloud Platformコンソールで新規プロジェクトを作成。プロジェクトIDを確定させる。

LINE BOT(チャンネル)の開設・設定

  • BOT API Trial Accountを開設
  • 開設したチャンネルの"Basic Information"にある"Callback URL"に、AppEngine側のコールバックを受け取るURLを設定
    • "https:// YOUR_PROJECT_ID .appspot.com:443/line/callback"
  • "Server IP Whitelist"は設定しない。設定するとAppEngineのIPアドレス変動に対応する必要があるため

必要なパッケージのインストール

$ goapp get github.com/line/line-bot-sdk-go/linebot
$ goapp get golang.org/x/text/encoding/japanese
$ goapp get golang.org/x/text/transform

LINE BOTの基本部分を実装

LINEからのコールバック用のハンドラをfunc init()に定義する。パスは上で定義したもの。

func init() {
    http.HandleFunc("/line/callback", lineCallback)
}

lineCallback()の中身は、ほぼBOT SDKのサンプルをコピペ。ただし、App Engineではhttp.Clientとしてappengine/urlfetchを使用する必要があるため、LINE BOT Clientからもこれを使うように、下記のように初期化する。

bot, err := linebot.NewClient(channelID, channelSecret, channelMID,
                        linebot.WithHTTPClient(urlfetch.Client(c)))

ここで一度goapp deployして動作確認。BOTを友達登録して、トークで送った内容をオウム返ししてくれればok.

友だち登録時にMIDを取得してデータストアに格納する

LINEへのメッセージ送信には、toにMID(LINEユーザの固有ID)を指定する必要がある。友だちに一斉配信するためには、あらかじめ友だち登録時に相手のMIDを取得しデータストアに保存する。

友だち登録および解除は、メッセージとは別フォーマットのオペレーション・イベントが通知される。パラメタのOpTypeによって、追加(ブロック解除も等価)、削除を判別できる。

なお、メッセージとは送信者のMIDが格納されている位置が異なる。OperationContent.Params[0]に入っているので注意。

    if content.IsOperation {
        //オペレーションイベント受信
        opContent, err := content.OperationContent()
        if content.OpType == linebot.OpTypeAddedAsFriend {
            task := taskqueue.NewPOSTTask("/task/addfriend", url.Values{
                "mid": {opContent.Params[0]},
            })
            taskqueue.Add(c, task, "default")
        } else if content.OpType == linebot.OpTypeBlocked {
            task := taskqueue.NewPOSTTask("/task/removefriend", url.Values{
                "mid": {opContent.Params[0]},
            })
            taskqueue.Add(c, task, "default")
        }
    } else if content.IsMessage && content.ContentType == linebot.ContentTypeText {
        //テキストメッセージ受信
        (snip)
    }

Task Queue/task/addfriendは送信者のMIDをデータストアに保存、/task/removefriendは送信者のMIDをデータストアから削除するもの。

メッセージを全員に送信

テキストメッセージを受信したとき処理を、送信者にオウム返しするもの(サンプルの実装)から、データストアに保存されている全MIDへの送信に変更する。

//データストアから購読者のMIDを取得
q := datastore.NewQuery("Subscriber")
var subscribers []Subscriber
if _, err := q.GetAll(c, &subscribers); err != nil {
    return err
}
mids := make([]string, len(subscribers))
for i, current := range subscribers {
    mids[i] = current.MID
}

//全員に送信
bot.SendText(mids, message)

調整さんリマインダ処理

調整さんリマインダは、下記処理をcronで毎朝起動されるようにする。

調整さんのイベントをクロール

cronからキックされるハンドラをfunc init()に追加する。

http.HandleFunc("/cron/crawlchouseisan", crawlChouseisan)

以下、crawlChouseisan()の実装。

調整さんのAPIは公開されていないが、イベント作成者にのみ表示される「出欠表をダウンロード」リンクのURLから、csv形式のレスポンスが得られるのでこれを利用する。

調整さんのイベントハッシュは、イベント作成時にコピーしておき、app.yamlに定義したものを取得する。

url := "https://chouseisan.com/schedule/List/createCsv?h=" + os.Getenv("CHOUSEISAN_EVENT_HASH")
c := appengine.NewContext(r)
client := urlfetch.Client(c)
res, err := client.Get(url)

csvをパースする。MS932なのでjapanese.ShiftJISデコーダを使用している。

また、encoding/csvReaderは、1レコード目と異なるカラム数のレコードをエラーと判断する。調整さんのcsvは1レコード目がタイトルのみとなっており、素直に読むとデータ行で"wrong number of fields in line"というエラーメッセージが出てしまうので、下記にようにErrFieldCountは無視するようにした。

reader := csv.NewReader(transform.NewReader(csvBody, japanese.ShiftJIS.NewDecoder()))
for {
    row, err := reader.Read()
    if err == io.EOF {
        break
    } else if e2, ok := err.(*csv.ParseError); ok && e2.Err == csv.ErrFieldCount {
        //フィールド数エラーは無視
    } else if err != nil {
        log.Errorf(c, "Read chouseisan's csv failed. err: %v", err)
        return nil
    }
    (snip)

日付などのパース処理は割愛。日付をキーにしたMapに詰める。

3日後の予定があれば通知

AddDate()で3日後を指すDateを作り、Mapにその日のValueが存在すれば通知対象とする。なお、App Engine実行環境ではUTCが使われるため、JSTを明示的に使用する。

tz, _ := time.LoadLocation("Asia/Tokyo")
today := time.Now().In(tz)
(snip)

//3日後の予定をピック
after3days := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, tz).AddDate(0, 0, 3)
obj, exist := m[after3days.String()]
obj, exist := m[after3days]
if !exist {
    log.Infof(c, "Not found schedule at 3 days after.")
    return
}

//メッセージを組み立てて送信
(snip)

cron.yamlを作成

作成したハンドラを、毎朝8:00 JSTに起動されるようにcronを定義。

cron:
- description: crawl chouseisan every day
  url: /cron/crawlchouseisan
  schedule: every day 08:00
  timezone: Asia/Tokyo

以上をデプロイして、今のところ想定通り動作している模様。

途中、シネマカリテで『不思議惑星キン・ザ・ザ』<デジタル・リマスター版>を観た影響で、ひたすら「クー」と返す "キン・ザ・ザBOT" に作り変えたくなったけど、よく我慢した。

ソース

下記リポジトリで公開しています(Apache License 2.0)

GitHub - nowsprinting/ChouseisanReminder: Reminder line-bot for chouseisan.com

はじめてのGoでありAppEngine/Goアプリなので、作法とかなっとらん自覚はあります。なので参考程度に。『みんなのGo言語』ポチったので届いたら読んで改善したい。

参考

*1:りんなはグループに登録できるので、トライアルの仕様?