概要
これまで自分は、経費管理のために様々なレシートを保存してきた。
しかし、レシートの保管やアップロードのために使う工数がばかにならないので、無料で簡単にGoogleDriveとMoneyForwardに画像アップロードする方法がないか探したところ表題のシステムを実装するに至った。
LINEで画像とタイトルを入力して送信すると、GoogleDriveとMoneyForwardにアップロードされるというシンプルなもの。
公式docsでも書いていないことがあったり、結合して動かした時とかログが途中で禿げてたりしてよくわからないことも多かったので個人的な詰まりポイントをメモした。
(拙い内容です)
できたこと
1.LIFFでフォームを表示して、ファイル名・経費申請者・画像ファイル を入力し送信・アップロードすること
iPhoneで撮ったレシートの写真を1枚選び、現在時刻をファイル名としてGoogleDriveの該当月フォルダにアップロードし、その後入力フォームにあるファイル名と経費申請者、画像ファイルを元として、マネーフォワードに経費明細をアップロードできた。
2.LINEのメッセージで送信された複数枚の画像を現在時刻をファイル名としてGoogleDriveにアップロードすること
LIFFを使わずにLINEBotに対する個別メッセージで、任意枚数の画像を送信することで、送った各画像をその月のフォルダにアップロードできた。
アップロード一連の流れ

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



アップロード結果


システム概要
- インフラ
- サーバサイド
- Go
- クライアントサイド
- LINE
- LIFF
- LineBot
- LINE
- 連携先
- GoogleDrive
-
"google.golang.org/api/drive/v3"
をつかった
-
- MoneyForward
- GoogleDrive
下準備
各パートでやっておくことをざっくりまとめてみた
LINEセットアップ
-
Line公式アカウントを作成
- 応答メッセージ設定の中にデフォルトで作成されてるメッセージを削除するのを忘れずに
- 作成した公式アカウントのリッチメニューを作成する
- サイズはおまかせ
- あとでLIFFで生成するURLをリンクとして設定する
- MessagingAPIを有効化
-
Channel secret
をメモしとく - webhookURLにコールバックURLを設定する
https://{{app_name}}.herokuapp.com/callback
-
- LINE Developersでプロバイダ作成してMessagingAPIチャネル作成
- 作ったプロバイダのアクセストークン発行
- MessagingAPIタブの下の方で発行できる
- 作成したプロバイダ配下でLINE loginチャネルを作成
- 該当LINE loginチャネルでLIFFを有効にする
- LIFF IDとURLをあとで使う
GoogleDriveセットアップ
- GCPのプロジェクトからサービスアカウント発行
- 発行したアカウントの認証キーをJSONで発行して大切に保管
- ファイルをアップロードしたいGoogleDriveフォルダの編集権限をサービスアカウントへ与える
Goセットアップ
- 依存関係管理のためのパッケージインストール
go get github.com/tools/godep
go get github.com/kardianos/govendor
- Gitプロジェクト作成して下記実行
go get github.com/line/line-bot-sdk-go/linebot
go mod init github.com/$(Username)/$(Repositoryname)
- 依存関係書き出し
godep save
govendor fetch +out
go mod vendor
Herokuセットアップ
- 管理画面から適当なパイプラインとアプリを作成する
- deployタブから、利用するgithubリポジトリと連携する
-
repositoryに含むファイルについて
- Procfileには、
web: bin/{{repository_name}}
を記述しておく - go.modにheroku用コメント追加
-
go1.xx
の上に、// +heroku goVersion go1.xx
を追記
-
- Procfileには、
-
repositoryに含むファイルについて
- 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_id
はoffices
のレスポンスで確認できる。設定画面で確認できる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については、Parents
とDriveId
ともにアップロード先ディレクトリの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をちゃんと触るのはほぼ初めてだがなんとか動いてよかった。
この記事を書いてみるとあっという間だったけど、結構時間を使ってしまった。
リファクタリングして、ゆくゆくは公開して誰でも使えるようにしていきたいと思ってる。