GoでLinebotとLIFFを使ってGoogleDriveとMoneyForwardに領収書をアップロードしてみる

# 概要
これまで自分は、経費管理のために様々なレシートを保存してきた。
しかし、レシートの保管やアップロードのために使う工数がばかにならないので、**無料**で簡単にGoogleDriveとMoneyForwardに画像アップロードする方法がないか探したところ表題のシステムを実装するに至った。

**LINEで画像とタイトルを入力して送信すると、GoogleDriveとMoneyForwardにアップロードされる**というシンプルなもの。
公式docsでも書いていないことがあったり、結合して動かした時とかログが途中で禿げてたりしてよくわからないことも多かったので個人的な詰まりポイントをメモした。

(拙い内容です)

# できたこと
**1.LIFFでフォームを表示して、ファイル名・経費申請者・画像ファイル を入力し送信・アップロードすること**

iPhoneで撮ったレシートの写真を1枚選び、現在時刻をファイル名としてGoogleDriveの該当月フォルダにアップロードし、その後入力フォームにあるファイル名と経費申請者、画像ファイルを元として、マネーフォワードに経費明細をアップロードできた。

**2.LINEのメッセージで送信された複数枚の画像を現在時刻をファイル名としてGoogleDriveにアップロードすること**

LIFFを使わずにLINEBotに対する個別メッセージで、任意枚数の画像を送信することで、送った各画像をその月のフォルダにアップロードできた。

### アップロード一連の流れ
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/262071/1f0adbe9-c69f-68e0-9447-f3c2b0eaa649.gif" width="70%">


### リッチメニューから送信フォームを開いてアップロード(個別メッセージは割愛)

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/262071/b0c9c921-a199-ef13-bd68-428e5cd68b52.png" width="33%">
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/262071/99782f39-ca6e-887c-37b5-3c8fb5db2de5.png" width="30%">
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/262071/e464e17d-d822-dfd1-c9cc-14e17073f9cd.png" width="30%">

### アップロード結果
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/262071/1eeae902-1ad2-b365-bd6d-a31190d78568.png" width="50%">
<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/262071/3bf6cae4-bf98-625c-843e-d593cabd303f.png" width="50%">


# システム概要
- インフラ
    - [Heroku](https://dashboard.heroku.com/)
- サーバサイド
    - Go
- クライアントサイド
    - LINE
        - [LIFF](https://developers.line.biz/ja/docs/liff/overview/)
        - LineBot
- 連携先
    - GoogleDrive
        - `"google.golang.org/api/drive/v3"`をつかった
    - MoneyForward
        - [MoneyForwardAPI連携について](https://github.com/moneyforward/expense-api-doc)

# 下準備
各パートでやっておくことをざっくりまとめてみた

### LINEセットアップ
1. [Line公式アカウントを作成](https://manager.line.biz/)
    - 応答メッセージ設定の中にデフォルトで作成されてるメッセージを削除するのを忘れずに
1. 作成した公式アカウントのリッチメニューを作成する
    - サイズはおまかせ
    - あとでLIFFで生成するURLをリンクとして設定する
1. MessagingAPIを有効化
    - `Channel secret`をメモしとく
    - webhookURLにコールバックURLを設定する
        - `https://{{app_name}}.herokuapp.com/callback`
1. [LINE Developers](https://developers.line.biz/ja/)でプロバイダ作成してMessagingAPIチャネル作成
1. 作ったプロバイダのアクセストークン発行
    - MessagingAPIタブの下の方で発行できる
1. 作成したプロバイダ配下でLINE loginチャネルを作成
1. 該当LINE loginチャネルでLIFFを有効にする
    - LIFF IDとURLをあとで使う

### GoogleDriveセットアップ
1. [GCP](https://console.cloud.google.com/)のプロジェクトからサービスアカウント発行
1. 発行したアカウントの認証キーをJSONで発行して大切に保管
1. ファイルをアップロードしたいGoogleDriveフォルダの編集権限をサービスアカウントへ与える

### Goセットアップ
1. 依存関係管理のためのパッケージインストール
    - `go get github.com/tools/godep`
    - `go get github.com/kardianos/govendor`
1. Gitプロジェクト作成して下記実行
    - `go get github.com/line/line-bot-sdk-go/linebot`
    - `go mod init github.com/$(Username)/$(Repositoryname)`
1. 依存関係書き出し
    - `godep save`
    - `govendor fetch +out`
    - `go mod vendor`

### Herokuセットアップ
1. [管理画面](https://dashboard.heroku.com/apps)から適当なパイプラインとアプリを作成する
1. deployタブから、利用するgithubリポジトリと連携する
    - **repositoryに含むファイルについて**
        - Procfileには、`web: bin/{{repository_name}}`を記述しておく
        - go.modにheroku用コメント追加
            - `go1.xx` の上に、`// +heroku goVersion go1.xx`を追記
1. settingsタブのConfigVarsに以下の値を設定する(key : value)
    - `GOOGLE_APPLICATION_CREDENTIALS` : `Googleサービスアカウントの認証鍵を置いてあるところのpath`
    - `LINE_BOT_CHANNEL_SECRET` : `LINEBIZで発行したChannelSecret`
    - `LINE_BOT_CHANNEL_TOKEN` : `LINEDEVで発行したMessagingAPIのAccessToken`

# 実装の前にちょっとだけ全体像整理
ザックリどんな流れで動かすかイメージをつかみたい
```plantuml
@startuml
participant LIFF as c
participant Main as main
participant MoneyForwardAuthoricator as mfa
participant GoogleDrive as gd
participant MoneyForward as mf

c -> main: リッチメニューからレシートの\nアップロードフォームを開くリクエスト
main -> mfa: Oauth Request
mfa --> main: Oauth Redircet
main -> mfa: IssueToken Request
mfa --> main: IssueToken Response
main -> c: \nマネフォ認証がOKだったらフォーム画面を表示

c -> c: \nユーザがレシートの情報と画像をフォーム入力する

c -> main: \nレシートアップロードリクエスト
main -> gd: UploadFiles Request
gd --> main: UploadFiles Response
main -> mf: UploadFilesInc(ImageInfo) Request
mf --> main: UploadFilesInc(ImageInfo) Response
main --> c: レシートアップロード結果画面表示
@enduml
```

# いざ実装
エラーハンドリングは有識者の方お願いします
パーツで分けて記載していくので、使えそうなところを抜いてもらえるといいかも

### linebot client生成
```go
func main() {
	bot, err := linebot.New(
		os.Getenv("LINE_BOT_CHANNEL_SECRET"),
		os.Getenv("LINE_BOT_CHANNEL_TOKEN"),
	)
	if err != nil {
		log.Fatal(err)
	}

	if err := http.ListenAndServe(":"+os.Getenv("PORT"), nil); err != nil {
		log.Fatal(err)
	}
}
```

### MoneyForward Oauth AuthorizatioCode発行
各クレデンシャルは[MoneyForwardのoauth用アプリケーション作成ページ](https://expense.moneyforward.com/oauth/applications/new)から生成
oauthで設定するリダイレクトURLには、この次に記載してる`/auth_get_code`を設定してる

```go
http.HandleFunc("/auth", func(w http.ResponseWriter, req *http.Request) {
    MF_CLIENT_ID := os.Getenv("MF_CLIENT_ID")
    MF_REDIRECT_URL := os.Getenv("MF_REDIRECT_URL")
    MF_SCOPE := os.Getenv("MF_SCOPE")

    mf_auth_url := "https://expense.moneyforward.com/oauth/authorize?"

    auth_req, _ := http.NewRequest(http.MethodGet, mf_auth_url, nil)

    params := auth_req.URL.Query()
    params.Add("client_id", MF_CLIENT_ID)
    params.Add("redirect_uri", MF_REDIRECT_URL)
    params.Add("response_type", "code")
    params.Add("scope", MF_SCOPE)
    auth_req.URL.RawQuery = params.Encode()
    redirect_url := mf_auth_url + string(auth_req.URL.RawQuery)

    http.Redirect(w, req, redirect_url, 301)

})
```

### MoneyForward Oauth Token発行
ここは公式ドキュメント通りだと、Content-Typeを`application/x-www-form-urlencoded`にする必要があるが、それだとリクエストが通らないので`application/json`にしてる。
```go
http.HandleFunc("/auth_get_code", func(w http.ResponseWriter, req *http.Request) {
    path := req.URL
    query := path.Query()
    auth_code := query["code"][0]
    grant_type := "authorization_code"

    issue_token_url := "https://expense.moneyforward.com/oauth/token"

    rBody := &RequestBody{
        Client_id:     os.Getenv("MF_CLIENT_ID"),
        Client_secret: os.Getenv("MF_CLIENT_SECRET"),
        Redirect_uri:  os.Getenv("MF_REDIRECT_URL"),
        Grant_type:    grant_type,
        Code:          auth_code,
    }

    jsonRBody, err := json.Marshal(rBody)

    if err != nil {
        panic("Error")
    }

    byte_jsonRBody := bytes.NewBuffer(jsonRBody)

    issue_token_req, _ := http.NewRequest(http.MethodPost, issue_token_url, byte_jsonRBody)

    if err != nil {
        panic("Error")
    }

    // issue_token_req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
    issue_token_req.Header.Set("Content-Type", "application/json")
    issue_token_req.Header.Set("Accept", "*/*")

    client := new(http.Client)
    resp, err := client.Do(issue_token_req)
    if err != nil {
        panic("Error")
    }
    defer resp.Body.Close()

    body, _ := io.ReadAll(resp.Body)

    var token_resources TokenResource

    if err := json.Unmarshal(body, &token_resources); err != nil {
        fmt.Println(err)
        return
    }
})
```
### MoneyForwardへの連携
フォームから取得したデータを利用してそのままマネフォの`upload_receipt`にリクエストしてる。
`office_id`は[`offices`](https://expense.moneyforward.com/api/index.html#/office)のレスポンスで確認できる。設定画面で確認できる`xxx-xxx`ではなかった。

`office_member_id`は[従業員一覧](https://expense.moneyforward.com/office_members)からmember_idを取得したい従業員の編集画面を開いて
`https://expense.moneyforward.com/office_members/${office_member_id}/edit`
となっているので、そこから取得しておいて環境変数などに保存しておくのがよさそう。
```go
http.HandleFunc("/receipts_up_to_mf", func(w http.ResponseWriter, req *http.Request) {

    // アップロードリクエストのための準備
    office_id := os.Getenv("office_id")
    office_member_id := req.MultipartForm.Value["office_member_id"][0]
    receipts_title := req.MultipartForm.Value["receipts_title"][0]
    up_file := req.MultipartForm.File["receipts_data"][0]

    mf_upload_receipts := "https://expense.moneyforward.com/api/external/v1/offices/"+ string(office_id)+ "/office_members/" + string(office_member_id) + "/upload_receipt"

    up_f, err := up_file.Open()
    byte_up_f, err := io.ReadAll(up_f)
    if err != nil {
        fmt.Println("画像エンコード失敗")
    } 
    // upload_receiptは画像データをbase64で送るため
    base64UpFiles := base64.StdEncoding.EncodeToString(byte_up_f)
        
    mf_up_Body := &MFUploadRequestBody{
        Content:     base64UpFiles,
        ContentType: "image/jpeg",
        Filename:    receipts_title,
        ProcessType: "2", // 0:何もしない、1:オペレーター入力, 2:OCR処理
        SplitPdf:    false,
    }

    mfUpJsonBody, err := json.Marshal(mf_up_Body)

    if err != nil {
        panic("Error")
    }

    byte_mfUpJsonBody := bytes.NewBuffer(mfUpJsonBody)

    mf_upload_receipts_req, _ := http.NewRequest(http.MethodPost, mf_upload_receipts, byte_mfUpJsonBody)
    if err != nil {
        panic("Error")
    }

    mf_access_token := os.Getenv("MF_ACCESS_TOKEN")
    mf_upload_receipts_req.Header.Set("Authorization", mf_access_token)
    mf_upload_receipts_req.Header.Set("Content-Type", "application/json")
    mf_upload_receipts_req.Header.Set("Accept", "*/*")

    up_client := new(http.Client)
    mf_upload_receipts_resp, err := up_client.Do(mf_upload_receipts_req)
    if err != nil {
        fmt.Printf("エラー詳細は下記\n%v\n", err)
        panic("Error")
    }
    defer mf_upload_receipts_resp.Body.Close()

    up_res_body, _ := io.ReadAll(mf_upload_receipts_resp.Body)

    if mf_upload_receipts_resp.StatusCode == 200 {
        t, err := template.ParseFiles("Success_LIFF.html")
        if err != nil {
            panic(err.Error())
        }
        if err := t.Execute(w, nil); err != nil {
            panic(err.Error())
        }
    }

})
```

### GoogleDriveへのアップロード
GoogleDriveへのアップロードで一番詰まったのが、`receiptMetaData`の定義と`srv.Files.Create`周りだった。

receiptMetaDataについては、**`Parents`と`DriveId`ともにアップロード先ディレクトリのIDを指定しておく必要がある。**

DriveAPIの`Files.Create`について、外部から画像ファイルをアップロードするなら
- `Media(Reader)`を追加すること
- **`SupportsAllDrives`を追加すること** <- ここでかなり時間かかった

```go
http.HandleFunc("/receipts_up_to_drive", func(w http.ResponseWriter, req *http.Request) {

    // MultipartFormでリクエストされるため
    if err := req.ParseMultipartForm(32 << 20); err != nil {
        fmt.Println("ParseMultipartForm error")
    }

    files := req.MultipartForm.File["receipts_data"]

    for _, f := range files {
        as_f, err := f.Open()

        ctx := context.Background()
        srv, err := drive.NewService(ctx)

        if err != nil {
            log.Fatalf("Unable to retrieve Drive client: %v", err)
        }

        // date_generatorは"yyyy-mm-dd hh:mm:ss"で現在時刻を取得する関数
        img_name = date_generator()

        // ここに現在月と同じ月をアップロード先ディレクトリとするような処理あり

        file_name := img_name + ".jpg"

        receiptMetaData := &drive.File{
            Name:     file_name,
            MimeType: "image/jpeg",
            Parents: []string{
                upload_dir,
            },
            DriveId: upload_dir,
        }

        r, err := srv.Files.Create(receiptMetaData).Media(as_f).SupportsAllDrives(true).Do()
        if err != nil {
            Panic(err)
        }
    }
})
```
### LINE個別メッセージに対するcallback
linebotはユーザからのアクションによりeventを受信する。
よく使うeventとして、メッセージ受信、画像受信、スタンプ受信を場合わけしてる。
linebotは`GetMessageContent($messageID)`で指定したIDの画像メッセージに対するコンテンツを取り出せる。
取り出したデータのContent部分が画像データバイナリなのでそれをDriveに渡すようにしてる。
```go
// Setup HTTP Server for receiving requests from LINE platform
http.HandleFunc("/callback", func(w http.ResponseWriter, req *http.Request) {
    events, err := bot.ParseRequest(req)
    if err != nil {
        if err == linebot.ErrInvalidSignature {
            w.WriteHeader(400)
        } else {
            w.WriteHeader(500)
        }
        return
    }
    for _, event := range events {
        if event.Type == linebot.EventTypeMessage {
            switch message := event.Message.(type) {
            // メッセージ受信した場合
            case *linebot.TextMessage:
                res := message_hundler(message.Text)
                if res == " " {
                    fmt.Println("何も返さなくていいやつ")
                } else {
                    if _, err = bot.ReplyMessage(event.ReplyToken, linebot.NewTextMessage(res)).Do(); err != nil {
                        log.Print(err)
                    }
                }
            // 画像受信した場合
            case *linebot.ImageMessage:
                mc, err := bot.GetMessageContent(message.ID).Do()
                if err != nil {
                    log.Print(err)
                }

                ctx := context.Background()
                srv, err := drive.NewService(ctx)

                if err != nil {
                    log.Fatalf("Unable to retrieve Drive client: %v", err)
                }

                img_name = date_generator()

                // ここに現在月と同じ月をアップロード先ディレクトリとするような処理あり

                file_name := img_name + ".jpg"

                receiptMetaData := &drive.File{
                    Name:     file_name,
                    MimeType: "image/jpeg",
                    Parents: []string{
                        upload_dir,
                    },
                    DriveId: upload_dir,
                }

                r, err := srv.Files.Create(receiptMetaData).Media(mc.Content).SupportsAllDrives(true).Do()
                if err != nil {
                    res := "アップロード失敗"
                    bot.ReplyMessage(event.ReplyToken, linebot.NewTextMessage(res)).Do()
                    fmt.Printf("Failed to upload image: %v", err)
                }

                upload_success_text := "正常にアップロードできました。"

                bot.ReplyMessage(event.ReplyToken, linebot.NewTextMessage(upload_success_text)).Do()

            // スタンプ受信した場合
            case *linebot.StickerMessage:
                replyMessage := fmt.Sprintf(
                    "sticker id is %s, stickerResourceType is %s", message.StickerID, message.StickerResourceType)
                if _, err = bot.ReplyMessage(event.ReplyToken, linebot.NewTextMessage(replyMessage)).Do(); err != nil {
                    log.Print(err)
                }
            }
        }
    }
})
```

### LIFFで表示するフォーム画面
普通のペライチフォーム
複数枚画像送信するなら、`enctype`を`"multipart/form-data"`にするのだけ忘れなければ大丈夫
現状下記の実装ではフォームからの複数枚アップに対応しないようにしてるけど、マネフォ側に複数枚あげる方法がわかれば対応する予定
```html
<html>
<head>
    <meta http-equiv="Content-Type" content="text/html; charset=utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>レシート管理</title>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" rossorigin="anonymous">
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css">
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/themes/base/jquery-ui.min.css">
</head>
<body>

    <form id="form" enctype="multipart/form-data" class="w-75 mx-auto" action="レシートアップするpath" method="POST">
        <p class="mt-3">タイトル</p>
        <div>
            <input class="form-control w-100 mt-1" type="text" name="receipts_title" placeholder="レシートタイトル" required>
        </div>
        <p class="mt-3">従業員</p>
        <div>
            <select class="form-control w-100 mt-1" name="office_member_id" required>
                <option value="{{member_id}}">おす</option>
                <option value="{{member_id}}">すずき</option>
            </select>
        </div>
        <p class="mt-3">レシート</p>
        <div>
            <input type="file" name="receipts_data" required>
        </div>
        <input type="submit" class="mt-4 btn btn-primary" value="送信">
    </form>

    <script src="https://code.jquery.com/jquery-3.4.1.min.js"></script>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/jqueryui/1.12.1/jquery-ui.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/popper.js@1.16.0/dist/umd/popper.min.js" crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/js/bootstrap.min.js" crossorigin="anonymous"></script>
    <script charset="utf-8" src="https://static.line-scdn.net/liff/edge/2.1/sdk.js"></script>
    <script>

        $(document).ready(function () {
            const liffId = "{{liffId}}";
            initializeLiff(liffId);
        })

        function initializeLiff(liffId) {
            liff.init({
                liffId: liffId
            }).then(() => {
                initializeApp();
            }).catch((err) => {
                console.log('LIFF Initialization failed ', err);
            });
        }

    </script>

</body>
</html>
```

# 振り返り
Goをちゃんと触るのはほぼ初めてだがなんとか動いてよかった。
この記事を書いてみるとあっという間だったけど、結構時間を使ってしまった。
リファクタリングして、ゆくゆくは公開して誰でも使えるようにしていきたいと思ってる。