LoginSignup
4
3

More than 1 year has passed since last update.

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

Last updated at Posted at 2022-07-03

概要

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

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

(拙い内容です)

できたこと

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

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

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

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

アップロード一連の流れ

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

アップロード結果

システム概要

下準備

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

LINEセットアップ

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

GoogleDriveセットアップ

  1. GCPのプロジェクトからサービスアカウント発行
  2. 発行したアカウントの認証キーをJSONで発行して大切に保管
  3. ファイルをアップロードしたいGoogleDriveフォルダの編集権限をサービスアカウントへ与える

Goセットアップ

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

Herokuセットアップ

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

実装の前にちょっとだけ全体像整理

ザックリどんな流れで動かすかイメージをつかみたい

いざ実装

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

linebot client生成

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用アプリケーション作成ページから生成
oauthで設定するリダイレクトURLには、この次に記載してる/auth_get_codeを設定してる

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にしてる。

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_idofficesのレスポンスで確認できる。設定画面で確認できるxxx-xxxではなかった。

office_member_id従業員一覧からmember_idを取得したい従業員の編集画面を開いて
https://expense.moneyforward.com/office_members/${office_member_id}/edit
となっているので、そこから取得しておいて環境変数などに保存しておくのがよさそう。

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については、ParentsDriveIdともにアップロード先ディレクトリのIDを指定しておく必要がある。

DriveAPIのFiles.Createについて、外部から画像ファイルをアップロードするなら

  • Media(Reader)を追加すること
  • SupportsAllDrivesを追加すること <- ここでかなり時間かかった
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に渡すようにしてる。

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

4
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
3