Posted at

LINE BOT をgoで作ってみたよ

More than 3 years have passed since last update.


LINE BOT ってなんぞ


  • 2016/4/7にBOT API Trial Accountが無償で公開された

  • BOT用のアカウントの発行が可能

  • 友達追加、トークの受信などをトリガーに自身が用意さいたサーバーに情報がコールバックで渡される


何ができるの


  • 寂しさを紛らわせられる

  • 50人まで友達作れる

  • 友達のプロフィールの閲覧

  • テキストメッセージの送信

  • 画像メッセージの送信

  • 動画メッセージの送信

  • 音声メッセージの送信

  • 位置情報メッセージの送信

  • スタンプの送信


準備物


  • LINE Business Centerのアカウント

  • Channnels(BOTのアカウント)

  • HTTPS通信がポート指定で受けられるサーバー


環境作ってみよう

今回はオレオレ証明書では許してもらえないので、ちゃんとした証明書を用意する必要があります。

お遊びで作るので、わんこ。のお小遣いでまかなえるようできるだけ低コストに…。


LINE 関係アカウント

おぐぐりくださいませ。


HTTPS通信できるサーバーの準備

今回はAWSで構築します。

AWSの基本的な使い方等はおぐぐりください。

ということで、CloudFrontで独自ドメインのSSL環境を構築して、

EC2でhttpを受け取る環境にしました

今回わんこ。が使ったものは以下の通りです。


  • EC2

  • ElasticIP

  • Certificate Manager

  • CloudFront

  • Route53


EC2

t2.nano を用意します。

OSはなんでもいいです。お好きなものをどうぞ。

(t2系インスタンスではEC2-Classicでのネットワーク構築は出来ないので、強制的にVPCになります


ElasticIP

新しいIPを発行して、EC2のいるVPCに設定します。

この時何故かわんこ。の環境では、パブリックDNSが表示されない問題がありました。

が、所詮EC2なので、ec2-{IP-ADDRESS}.ap-northeast-1.compute.amazonaws.comみたいな感じでping打ってみたら通ってしまったので、そちらを使います。

(CloudFrontのoriginにはIPだけじゃ設定できないので、名前が必要になります。


Certificate Manager

これは結構最近?リリースされたAWSの証明書管理のソリューションです。

CloudFrontや、ELBに対して、無料で共用の証明書を発行してくれます。

素晴らしい!!!!!(バージニアリージョンだけなんですけどね

今回は独自ドメインを利用するので、line.{自分のドメイン.com}の証明書を発行しました。

(ワイルドカード指定も出来ます

コンソール上で操作すると、ドメインの管理者にAWSからメールが届きますので、内容に問題がなければ承認しましょう。

これで証明書の発行は終わりです。

簡単!素晴らしい!!!!


CloudFront

新しいDistributionを作成します。

設定する項目は


  • Alternate Domain Names(CNAMEs)

  • SSL Certificate

  • Custom SSL Client Support

  • Origin Domain Name

上記 4項目です。


Alternate Domain Names(CNAMEs)

これはCloudFrontにアクセスするためのFQDNです。

line.{自分のドメイン.com}と入力しましょう。


SSL Certificate

ここが重要です。

Custom SSL Certificate を選択し、先ほどCertificate Managerで作成した証明書を選択します。


Custom SSL Client Support

Only Clients that Support Server Name Indication (SNI)を選択

AllClientsだと、毎月さらに料金かかっちゃう…。


Origin Domain Name

ここにEC2のパブリックDNSを登録します。


Route53

このままだと、独自ドメインがまだCloudFrontに向いてないので

Aレコードを設定します。

CreateRecordSetを選択し、


  • Nameにlineと入力

  • Alias を Yes

  • Alias Target に先ほど作ったCloudFrontのDistributionを設定します。

以上!!!

そうすることで

LINE BOT Serevr <-https-> CloudFront <-HTTP-> EC2

このような通信が可能になります。

やったね!


やっとgoの話

LINEのBOT SERVERからくるコールバックリクエストは、

自身のサーバーがきちんと受領した状態だということをレスポンスに返せばいいので、受け取ったらヘッダーとBODYを読み取って、さっさとStatusOKを返してしまいましょう。


lineBothandler.go

// LineBotHandler /line/bot へのハンドラ

func LineBotHandler(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(200)
if body, err := ioutil.ReadAll(r.Body); err == nil {
// さっさとレスポンスだけ返してあげるために ゴルーチンで処理します
go line.Receive(body)
} else {
glog.Errorf("読み込めなかったよ %v", err)
}
defer r.Body.Close()

// とりあえずすぐ返す
w.Header().Set("Content-Type", "text/html; charset=utf8")
w.Write([]byte("OK"))
}


こんな感じのものをnet/httpのハンドラに登録してあげれば出来上がりです。


BOT SERVER からの通知を受け取る

上記では内容は無視してレスポンス返してたので、送られたきたJSONを解読してあげましょう。


receive.go


// Notification コールバック叩かれたときのリクエストをパースするよ
type Notification struct {
Result []*Event `json:"result"`
}

// Event 一つの投稿をあわらす
type Event struct {
Content *Content `json:"content"`
CreatedTime *JsonTime `json:"createdTime"`
EventType string `json:"eventType"`
From string `json:"from"`
FromChannel int `json:"fromChannel"`
ID string `json:"id"`
To []string `json:"to"`
ToChannel int `json:"toChannel"`
}

// Content 中身
type Content struct {
ContentMetadata *ContentMetadata `json:"contentMedadata"`
ContentType int `json:"contentType"`
CreatedTime *JsonTime `json:"createdTime"`
DeliveredTime int `json:"deliveredTime"`
From string `json:"from"`
ID string `json:"id"`
Location *Location `json:"location"`
Seq string `json:"seq"`
Text string `json:"text"`
To []string `json:"to"`
}

// ContentMetadata メタデータ
type ContentMetadata struct {
RecvMode int `json:"AT_RECV_MODE"`
Emtver int `json:"EMTVER"`
}

// JsonTime parse用の時間
type JsonTime struct {
time.Time
}

// UnmarshalJSON JSONのタイムスタンプから変換する用
func (j *JsonTime) UnmarshalJSON(data []byte) error {
i, err := strconv.ParseInt(string(data), 10, 64)
if err != nil {
return err
}
j.Time = time.Unix(i, 0)
return nil
}

var _ json.Unmarshaler = (*JsonTime)(nil)

// Location 位置情報
type Location struct {
Title string `json:"title"`
Address string `json:"address"`
Latitude float32 `json:"latitude"`
Longitude float32 `json:"longitude"`
}


Unixタイムスタンプからそのままtime.Timeにはtime.UnmarshalJSONが対応していなかったので自分で作ります。


投稿しましょ。

Eventを受け取ってどんな内容を返すのか、将又返さないかは実装次第です。

シュールなBOTできたら招待してください。楽しみたいです。


send.go

// Event 実際に送信される構造体

type Event struct {
To []string `json:"to"`
ToChannel int `json:"toChannel"`
EventType string `json:"eventType"`
Content interface{} `json:"content"`
}

// Content 全てのコンテンツのベース
type Content struct {
ContentType int `json:"contentType"`
ToType int `json:"toType"`
}

// TextContent テキスト送信系のコンテンツ
type TextContent struct {
*Content
Text string `json:"text"`
}

// ImageContent イメージ送信用のコンテンツ
type ImageContent struct {
*Content
OriginalContentUrl string `json:"originalContentUrl"`
PreviewImageUrl string `json:"previewImageUrl"`
}


こんな感じでテキストと画像の投稿用のEventを作ることが出来ます。

Event.Content に対して、TextContent or ImageContentを設定してあげる感じでしょうか。


送信用ワーカー

上記のEventをchanで受け取って、別のゴルーチンで送信処理をするようにします。


sender.go


// API_ENDOINT LINE のAPIエンドポイント
const API_ENDOINT string = "https://trialbot-api.line.me/v1/events"

var sender chan *Event

func init() {
sender = make(chan *Event)
}

// Send 送信用ルーチン
func Send() {
for event := range sender {
go func(e *send.Event) {
// じゃあAPI叩いて送るべさ
Request(jsonEncode(e))
}(event)
}
}
// 送信用のJSONにして返すよ
func jsonEncode(event *Event) string {
j, err := json.Marshal(event)
if err != nil {
glog.Errorf("jsonにできませんでした[%v]", err)
return ""
}
return string(j)
}
// Request APIたたくーよ
func Request(body string) error {
client := &http.Client{}
//body := io.Reader
req, err := http.NewRequest("POST", API_ENDOINT, strings.NewReader(body))
if err != nil {
return err
}
req.Header.Add("Content-type", "application/json; charset=UTF-8")
req.Header.Add("X-Line-ChannelID", account.CHANNEL_ID)
req.Header.Add("X-Line-ChannelSecret", account.CHANNEL_SECRET)
req.Header.Add("X-Line-Trusted-User-With-ACL", account.MID)
resp, err := client.Do(req)
if err != nil {
return err
}
if b, err := ioutil.ReadAll(resp.Body); err == nil {
glog.Info(string(b))
} else {
glog.Errorf("読み込めなかったけど %v", err)
}
defer resp.Body.Close()
return nil
}


テキストと画像が送信できれば、ある程度のことはできるのではないでしょうか。

みんなでシュールなBOTがあふれる世の中にしていきましょう。


所感



  • ContentTypeによって、JSONの中の構造が結構変わるので(スタンプとか…)、interface{}に1回落としてから再度パースしなきゃいけなかった(多分これに限った話ではないけど

  • 画像や動画、音声の配信はバイナリ自体をPOSTするのではなく、URL形式で渡す形になる。一度インターネット上に公開しなきゃいけないので、恥ずかしいのは送りにくい

  • そんなに寂しさは紛れなかった

  • EC2からAWS IoTにつなげて、RaspberryPiとかに通知送れるようにしたい。

ちなみに、僕は最低賃金を返してくれるBOTを作りました。