はじめに
普段から買い物の内容を把握するためにレシートをもらうようにしているのですが、
どんどん溜まっていって結局は整理していないので、ゴミを集めているだけになってる
みたいなことありますよね?(自分だけじゃないはず・・・)
その場でデータにしておけば苦労せずに....というアプリはすでに世の中に溢れているんですが、
Dify触ってみたいということもあり、せっかくなので勉強がてら簡単な領収書管理アプリを作ってみます。
普段使っているLINE、無料で使えるGoogle Apps Script(以下、GAS)、簡単にLLMのアプリが作れると話題のDifyを連携して作っていきます。
記事の内容
• LINEとGASを連携させ、ユーザーのアップロードした画像を処理する方法
• GASを使って画像をDifyに送信する方法
• 領収書の画像からデータを抽出し、Googleスプレッドシートに保存する方法
※Difyは最低限しか使っていない(使わなくてもよい)ので、詳しい使い方は他記事を参考にしてください。
※本格的というより、とりあえずやってみたいよーと思ってる方向けです。
少し長いので3つに分割しています。
こちらはPert1 です。
↓Pert2 はこちら↓
↓Pert3 はこちら↓
アプリの流れ
利用イメージですが、LINEから画像をアップロードすると、
内容がスプレッドシートに書き込まれ結果をLINEで返してくれる
というものです。
<具体的な流れ>
①ユーザーがLINEで領収書の写真をアップロード
②LINEからGASへwebhookが送信される
③GASがそのリクエストを受け取り、画像データをDifyに送信
④Difyを通してLLMにて画像を解析し、領収書の内容をJSON形式にする
⑤GASがそのJSONデータをGoogleスプレッドシートに保存し、画像もGoogleドライブに保存する
⑥GASが保存した内容をLINEに返す
Step 1: LINE BOTを設定する
まず、ユーザーがLINEで領収書の画像を送信できるようにするために、LINEのbotを設定します。
・LINE Official Account Managerで公式アカウントを作成
・アイコンや説明、自動メッセージなどなど好きなものに設定
・LINE Developersでチャンネルを作成
・Messaging API および Webhookを有効にする
こちらの詳しい作成方法に関しては「LINE BOT 作り方」などで検索してもらうとすぐ出てきます。
↓詳細はこちら↓
Step 2: GASでwebhookを受け取る
ひとまず、GASでLINEからのメッセージを受け取れるようにします。
doGet、doPost関数などでリクエストを受け取れるAPIが作成できます。
ちょっとした個人で試したいwebhookの処理なんかは十分ですね。ありがたい!
2-1: 新規スクリプトを準備
以下のいずれかの方法でGoogle Apps Scriptを新規作成してください。
スプレッドシートの紐づいたApps Script
・まっさらなスプレッドシートを作成
・スプレッドシートを開いて、拡張機能>Apps Scriptを開く
単独のApps Script
・GoogleDrive上で右クリック
・その他からGoogle Apps Scriptを選択
いずれにしろスプレッドシート使うのですが、自分はファイルとして操作したかったので単独のScriptファイルとして作成しました。
2-2: doPostを書いてみる
作成したLINE公式アカウントにメッセージ(画像)を送ると指定したURL(このあと設定)に
Webhookイベントオブジェクトを含むHTTP POSTリクエストが送られてきます。
今回はPOSTリクエストを受け取るためにdoPost関数を使います。
/**
* LINE webhook からのリクエスト受け取り処理
* @param {GoogleAppsScript.Events.DoPost} e
* @return {GoogleAppsScript.Content.TextOutput}
*/
function doPost(e) {
if (!e) {
Logger.log('データがありません');
return ContentService.createTextOutput('データがありません');
}
const json = JSON.parse(e.postData.contents);
const replyToken = json.events[0].replyToken;
const message = json.events[0].message;
const messageType = message.type;
if (typeof replyToken === 'underfined') {
return ContentService.createTextOutput('リプライトークンが見つかりません');
}
// 画像以外のメッセージには対応しない
if (messageType !== 'image') {
replyMessageToLine(replyToken, '画像を送ってください');
return;
}
// テストメッセージ返却
replyMessageToLine(replyToken, '画像を受け取れました');
}
パラメーターについて
以下のようなjson形式でイベントオブジェクトを受け取れます。
{
"destination": "xxxxxxxxxx",
"events": [
{
"replyToken": "nHuyWiB7yP5Zw52FIkcQobQuGDXCTA",
"type": "message",
"mode": "active",
"timestamp": 1462629479859,
"source": {
"type": "group",
"groupId": "Ca56f94637c...",
"userId": "U4af4980629..."
},
"webhookEventId": "01FZ74A0TDDPYRVKNK77XKC3ZR",
"deliveryContext": {
"isRedelivery": false
},
"message": {
"id": "444573844083572737",
"type": "text",
"quoteToken": "q3Plxr4AgKd...",
"text": "@All @example Good Morning!! (love)",
"emojis": [
.....
必要な下記2つを取り出し
- replyToken:LINEで応答メッセージを送る際に必要なトークン
- message.type:メッセージのタイプ(テキスト/画像/動画/音声ファイル/位置情報/スタンプ)
const replyToken = json.events[0].replyToken;
const message = json.events[0].message;
const messageType = message.type;
message.type はテキストならtext
、画像ならimage
、動画ならvideo
となりますので、今回は画像を送ってきて欲しいのでそれ以外は「違うよ」と教えてあげるようにします。
↓パラメーターの詳細はこちら↓
応答返却(replyMessageToLine関数)について
この後、何度も応答メッセージの送信処理を使うので、別ファイルに独自関数として作成しておきました。
GASはファイルを分けても特にimportなど不要で認識してくれます。
そのため関数名つける時は、気を付ける必要がありますね!
/**
* LINEでリプライメッセージを投稿する
* @param {string} replyToken リクエストに含まれていたリプライトークン
* @param {string} messageText 送りたいメッセージ
* @return {UrlFetchApp.HTTPResponse} リクエスト結果のHTTPレスポンス
*/
function replyMessageToLine(replyToken, messageText){
const url = LINE_URL + '/reply';
const options = {
'headers': {
'Content-Type': 'application/json; charset=UTF-8',
'Authorization': 'Bearer ' + LINE_TOKEN,
},
'method': 'post',
'payload': JSON.stringify({
'replyToken': replyToken,
'messages': [{
'type': 'text',
'text': messageText,
}],
}),
}
const response = UrlFetchApp.fetch(url, options);
return JSON.parse(response);
}
↓応答メッセージの詳細はこちら↓
定数について
replyMessageToLine関数の中で使っている下記2つの定数をさらに別ファイルにしています。
/**
* LINE
*/
// LINE developersのメッセージ送受信設定に記載の アクセストークン
const LINE_TOKEN = PropertiesService.getScriptProperties().getProperty("LINE_TOKEN");
// LINEのURL
const LINE_URL = 'https://api.line.me/v2/bot/message';
トークンはうっかりが非常に危険なので
プロジェクトのスクリプトプロパティに設定して取り出すようにしています。
2-3: デプロイ+URL設定+テスト
ここまでをいったん動かしてみましょう!
GASのスクリプトをWEBアプリとしてデプロイ
・右上のデプロイボタンから新しいデプロイ
・ウェブアプリを選択して説明を入れてデプロイ
・デプロイ完了するとURLが出てくるのでそれをコピー
アクセスできるユーザーはLINEからのリクエストを受け付ける必要があるので全員にしてます。
本来はLINEからのリクエストかどうか署名の検証が必要なのですが、GASでヘッダーの取得出来なかったので省略しているので、悪意のあるものでも何でも受け付けてしまう状態です。
こちらのGASを使ってのアプリはあくまでお試しでやってみた程度のものとして考えてください。
初回は新しいデプロイですが、2回目以降はURLが変わらないように
デプロイボタン > デプロイを管理 > 編集 > 新バージョン にして更新していきます。
LINEのWebhook URLへ設定
LINE Developers > Messaging API設定 > Webhook設定より
デプロイ後にコピーしたURLを設定
検証ボタンも押して問題ないか確認しておきましょう!
ここまでをテスト
作成公式アカウント上でテストメッセージを送ってみると...
テキストメッセージを送ると「画像を送ってください」
画像を送ると「画像を受け取れました」
と返ってくれば成功です👏
Step 3: LINEで送った画像を取得する
続いて送ってきた画像を取り出してみます。
確認してみたところどうやら画像の提供のされ方は2種類のパターンがありました。
↓詳細はこちら↓
画像ファイルの提供元を表すcontentProvider.type(String)
が以下のどちらかによって決まるようです。
<line
になっていた場合>
コンテンツ取得用の別のエンドポイントにメッセージIDを指定して画像ファイルのバイナリデータを取得する
https://api-data.line.me/v2/bot/message/{messageId}/content
<external
になっていた場合>
contentProvider.originalContentUrl
から取得する
例
このようなcontentProvider.type
がline
となっていたら、
https://api-data.line.me/v2/bot/message/354718705033693859/content
にて取得する
{
"destination": "xxxxxxxxxx",
"events": [
{
"type": "message",
"message": {
"type": "image",
"id": "354718705033693859", ←messageId
"quoteToken": "q3Plxr4AgKd...",
"contentProvider": {
"type": "line" ←ここの値による
},
やること
message.typeから画像が送られてきていることまで確認したら
・LINEの画像提供元からコンテンツを取得
・コンテンツから日時のファイル名を生成
を行う、データとファイル名を元に次の処理に移れるよう準備していきます。
// 画像のBlobデータ取得
const imageBlob = getImageBlobByLineMessage(message);
// Blobデータから日時のファイル名を生成
const extension = getExtensionByMimeType(imageBlob.getContentType());
const fileName = generateTimestampedFileName(extension);
3-1: 画像取得用関数(getImageBlobByLineMessage)を作成
上記の2種類のパターンに対応して、画像データを取得する関数を作成。
レスポンスからBlobデータとして返却します。
// LINEのコンテンツ取得用URL
const LINE_CONTENT_URL = 'https://api-data.line.me/v2/bot/message/{messageId}/content'
/**
* contentProviderから画像取得
* @param {Object} message LINE web hook から届いたevents[0].messageのデータ
* @return {Blob} 画像データ
*/
function getImageBlobByLineMessage(message){
const contentProvider = message.contentProvider;
if(!contentProvider) {
throw new Error('画像データが見つかりません。')
}
if(contentProvider.type === 'line'){
const url = LINE_CONTENT_URL.replace('{messageId}',message.id);
const options = {
'headers': {
'Authorization': 'Bearer ' + LINE_TOKEN,
},
'method': 'get',
}
const responseLine = UrlFetchApp.fetch(url, options);
return responseLine.getBlob();
}
if (contentProvider.type === 'external'){
const contentUrl = contentProvider.originalContentUrl;
const responseExternal = UrlFetchApp.fetch(contentUrl);
return responseExternal.getBlob();
}
}
3-2: ファイル名生成関数(generateTimestampedFileName)を作成
今回はBlobデータしか取り扱わないのですが、ヘルパー関数ということで
画像のURLを扱う場合も使えるように、以下2つに分けて作成しました。
・MimeTypeから拡張子を判別する関数
・拡張子またはURLから日時ファイル名を生成する関数
/**
* MIME-TYPEから識別子を取得
* @param {string} mimeType BlobのMIME-Type .getContentType()で取得
* @return {string} 識別子
*/
function getExtensionByMimeType(mimeType){
if(mimeType === 'image/jpeg') return '.jpg';
if(mimeType === 'image/png') return '.png';
if(mimeType === 'image/gif') return '.gif';
return;
}
/**
* 現在日時のファイル名を生成
* @param {string} referenceString 識別子を含んだ文字列(URLかファイル名か識別子そのものか)
* @return {string} YYYYMMDDHHMMss.xxxの形のファイル名
*/
function generateTimestampedFileName(referenceString) {
const extension = referenceString.split('.').pop();
if(!extension) return;
const now = dayjs.dayjs().format('YYYYMMDDHHss');
return now + '.' + extension;
}
<2024−10−27追記>
記載不足がありました。
generateTimestampedFileName関数の中でdayjs
という日付を簡単に扱うために、ライブラリを使っています。
上のコードを使う場合はエディタ左メニュー「ライブラリ」の+ボタンよりdayjsをライブラリに追加して使ってください。
↓参考にさせていただきました。ありがとうございます!↓
3-3: テスト
LINEから画像送ってGoogleDriveに保存してみてテストをしてみます。
日時のファイル名で送った画像が無事保存されていれば成功です。
// 保存先GoogleDriveのフォルダID
const FOLDER_ID = '1234567890abcdefghijklmnopqrstuvwxyz';
function doPost(e) {
if (!e) {
Logger.log('データがありません');
return ContentService.createTextOutput('データがありません');
}
const json = JSON.parse(e.postData.contents);
const replyToken = json.events[0].replyToken;
const message = json.events[0].message;
const messageType = message.type;
if (typeof replyToken === 'underfined') {
return ContentService.createTextOutput('リプライトークンが見つかりません');
}
// 画像以外のメッセージには対応しない
if (messageType !== 'image') {
replyMessageToLine(replyToken, '画像を送ってください');
return;
}
// 画像のBlobデータ取得
const imageBlob = getImageBlobByLineMessage(message);
// Blobデータから日時のファイル名を生成
const extension = getExtensionByMimeType(imageBlob.getContentType());
const fileName = generateTimestampedFileName(extension);
// フォルダを取得
const folder = DriveApp.getFolderById(FOLDER_ID);
// ファイルを指定フォルダに保存
folder.createFile(imageBlob).setName(fileName);
}
さいごに
- 今回はここまで
- GASでwebhoookの処理は簡単ですが署名検証出来ないので、Google cloud functionsやAWS Lambdaで動した方がよさそうです
- 次回は受け取った画像をDifyにて解析するところをやってみます
続き
↓Pert2 はこちら↓
↓Pert3 はこちら↓