LoginSignup
4
3
記事投稿キャンペーン 「2024年!初アウトプットをしよう」

【0からGASを学ぶ】GAS × Gemini Pro API × LINE Messaging API の相談Botで画像を扱う

Last updated at Posted at 2024-01-01

はじめに

本シリーズでは、GASの始め方や便利な使い方、ビジネス活用まで幅広く解説します。シリーズをひと通り読んでいただければ、あなたもきっとGASマスターになれるはずです。

シリーズの対象者

  • そもそもGASってなんだかわからない
  • GASを学びたいけど何から始めればいいかわからない方
  • GASはわかり始めたけど、もっと活用ができないかと模索している方
  • とにかくGoogleが好き! という方

前回記事

Gemini Pro API をGASからアクセスする

では早速始めていきましょう。【0からGASを学ぶ】シリーズの第17回は「GAS × Gemini Pro API × LINE Messaging API の相談Botで画像を扱う」です。これまで第14回から第16回までGemini Pro APIを扱いましたが、今回が完結編です。これで、GAS × Gemini Pro API × LINE Messaging APIによる相談Botは文字と画像の入力による相談ができるように進化し、あなただけの相談Botはとても優秀な相談相手となるでしょう。

前回作成した相談Botを前提に記事を書いておりますので、まだ相談Botを作成していない場合は、必ずこちらを参考にして作成してください。

今回やること

  1. LINEによる画像送信時のGoogleドライブへの一時保存
  2. 画像データをGemini Pro Visionに渡して回答を受領

Gemini Pro Visionは、Googleが開発したマルチモーダル言語モデルです。テキストと画像の両方を入力として受け入れ、テキスト出力として生成します。Gemini Pro Visionは、テキストと画像の間の関連性を理解し、テキスト出力をより関連性のあるものにすることができます。

STEP.1 LINEによる画像送信時のGoogleドライブへの一時保存

LINEでは画像とテキストを同時におくることはできません。そこで、

  1. 画像を送信し、Googleドライブに一時保存する
  2. 次にテキストを送信し、一時保存された画像データとともにGemini Pro Visionに渡す

という仕様にしたいと思います。

仕様の確認

画像を含むメッセージオブジェクトに関するJSON

こちらを確認すると、contentProvider.typeline、つまりユーザーが画像ファイルを送信した場合

画像ファイルのバイナリデータは、メッセージIDを指定してコンテンツを取得するエンドポイントを使用することで取得できます。

とあります。そこで、コンテンツを取得するエンドポイントも併せて確認しましょう。

コンテンツを取得するエンドポイント

curl -v -X GET https://api-data.line.me/v2/bot/message/{messageId}/content \
-H 'Authorization: Bearer {channel access token}'

これを見ると、以下のことがわかります。

項目
Fetch先のURL https://api-data.line.me/v2/bot/message/{messageId}/content
method種別 GET
認証 Bearer {channel access token}

仕様を踏まえて実装

今回はGoogleドライブ上に画像ファイルを一時保存するため、一時保存領域を作成しておきましょう。

※私は同一フォルダにtmpを作成しました。このtmpのフォルダIDを確認しておいてください。

doPostの改修
const LINEAPI_TOKEN = '**第15回記事のSTEP.1のNo.17で取得したチャネルアクセストークンを記載する**';

+ const OUT_DIR = '**一時保存領域のフォルダID**';
/**
 * LINEのトークでメッセージが送信された際に起動するメソッド
 * @param {EventObject} e - イベントオブジェクト
 */
function doPost(e){
  // イベントデータはJSON形式となっているため、parseして取得
  const eventData = JSON.parse(e.postData.contents).events[0]
        , repToken = eventData.replyToken
        , msgType = eventData.message.type;
  // テキストメッセージのとき
  if (msgType=='text') {
    let uText = eventData.message.text
        , gemini = getGeminiProAnswerTxt(uText);
    replyTxt(repToken, gemini);
    sCache.put('user', uText.slice(0, 10000));
    sCache.put('model', gemini.slice(0, 10000));

+  // 画像メッセージのとき
+  } else if (msgType=='image') {
+    let imageId = getImageId4Create(eventData);
+  }
}

+/**
+ * LINEのトークで送信された画像をGoogleドライブに保存し、ファイルIDを返却するメソッド
+ * @param {EventObject} e - イベントオブジェクト
+ */
+function getImageId4Create(e) {
+  const url = 'https://api-data.line.me/v2/bot/message/' + e.message.id + '/content'
+        , options = { 
+            'method': 'get',
+            'headers': {
+              'Authorization': 'Bearer ' + LINEAPI_TOKEN,
+            }
+          };
+  const data = UrlFetchApp.fetch(url, options)
+        , imageData = data.getBlob().getAs('image/png').setName(Number(new Date()));
+  return DriveApp.getFolderById(OUT_DIR).createFile(imageData).getId();
+}

上記のようにmsgTypeで判別して、imageであった場合にはコンテンツを取得するエンドポイントを用いてデータを取得し、Googleドライブへ保存します。

STEP.2 画像データをGemini Pro Visionに渡して回答を受領

仕様の確認

これまではGemini Proを利用していましたが、今回はGemini Pro Visionを利用するため、APIの仕様を確認しましょう。

echo '{
  "contents":[
    {
      "parts":[
        {"text": "What is this picture?"},
        {
          "inline_data": {
            "mime_type":"image/jpeg",
            "data": "'$(base64 -w0 image.jpg)'"
          }
        }
      ]
    }
  ]
}' > request.json

curl https://generativelanguage.googleapis.com/v1beta/models/gemini-pro-vision:generateContent?key=${API_KEY} \
        -H 'Content-Type: application/json' \
        -d @request.json 2> /dev/null | grep "text"

これまではcontentsには'parts' > 'text'のみ(roleの指定もあり)でしたが、ここにinline_dataを指定し、database64で渡せば、Gemini Pro Visionは受け付けてくれるようです。

仕様を検討

また、今回の場合は画像メッセージを送信し、次にテキストメッセージを送信すると2つのイベントが発生しますが、GAS側から見るとこの2つのイベントは独立しています。そのため、後者のテキストメッセージを送信した際にその前にユーザが画像を送信していたという事実をGAS側は何らかの手段を使わないと理解できません。

では、どうすればよいでしょうか。ここで登場するのが、前回ご紹介した Cache です。つまり、画像データをGoogleドライブに保存し、この際のファイルIDをScriptCacheに保存しておきます。あとはテキストメッセージを受信した際に、ScriptCacheが存在するか否かで処理を分岐させればばっちりです。

実装

それではやってみましょう。

getGeminiProAnswerTxtの改修
const GEMINI_API    = '**第14回記事のSTEP.1のNo.5で取得したAPI keyを記載する**';
const REPLY_URL     = 'https://api.line.me/v2/bot/message/reply';
const LINEAPI_TOKEN = '**第15回記事のSTEP.1のNo.17で取得したチャネルアクセストークンを記載する**';

const OUT_DIR       = '**一時保存領域のフォルダID**';
const sCache = CacheService.getScriptCache();
/**
 * LINEのトークでメッセージが送信された際に起動するメソッド
 * @param {EventObject} e - イベントオブジェクト
 */
function doPost(e){
  // イベントデータはJSON形式となっているため、parseして取得
  const eventData = JSON.parse(e.postData.contents).events[0]
        , repToken = eventData.replyToken
        , msgType = eventData.message.type;
  // テキストメッセージのとき
  if (msgType=='text') {
    let uText = eventData.message.text
-        , gemini = getGeminiProAnswerTxt(uText);
+        , gemini;
+    if (!sCache.get('image')) {
+      gemini = getGeminiProAnswerTxt(uText);
+    } else {
+      gemini = getGeminiProVisionAnswerTxt(uText, sCache.get('image'));
+      DriveApp.getFileById(sCache.get('image')).setTrashed(true);
+      sCache.remove('image');
+    }
    replyTxt(repToken, gemini);
    sCache.put('user', uText.slice(0, 10000));
    sCache.put('model', gemini.slice(0, 10000));

  // 画像メッセージのとき
  } else if (msgType=='image') {
    let imageId = getImageId4Create(eventData);
+    sCache.put('image', imageId);
+    replyTxt(repToken, '送信された画像について聞きたいことは何ですか?');
  }
}

+/**
+ * LINEのトークに送信されたメッセージをGemini Pro Vision APIに渡して回答を得るメソッド
+ * @param {String} txt - 送信されたメッセージ
+ */
+function getGeminiProVisionAnswerTxt(txt, imageid) {
+  try {  
+    const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-pro-vision:generateContent?key=${GEMINI_API}`
+          , payload = {
+              'contents': [{
+                'parts': [{
+                  'text': txt 
+                },
+                {
+                  'inlineData': {
+                    'mimeType': 'image/png',
+                    'data': Utilities.base64Encode(DriveApp.getFileById(imageid).getBlob().getBytes())
+                  }
+                }]
+              }]
+            }
+          , options = {
+              'method': 'post',
+              'contentType': 'application/json',
+              'payload': JSON.stringify(payload)
+            };
+
+    const res = UrlFetchApp.fetch(url, options)
+          , resJson = JSON.parse(res.getContentText());
+
+    if (resJson && resJson.candidates && resJson.candidates.length > 0) {
+      return resJson.candidates[0].content.parts[0].text;
+    } else {
+      return '申し訳ございません。お答えできません。';
+    }
+  } catch (ex) {
+    writeLog(ex.toString());
+    return '申し訳ございません。Gemini Pro Visionの呼び出しで異常終了しました。';
+  }
+}

このようにすることでsCache.get('image')がある、つまりは前回の画像保存データがある場合は、入力テキストと前回保存した画像をBase64にエンコードしたうえで、Gemini Pro Visionに渡します。

getGeminiProVisionAnswerTxtを呼び出したあとは、

  1. 一時保存した画像を削除
  2. Cacheに設定した画像ファイルIDを削除

することを忘れずに行いましょう。前者を行わないとGoogleドライブがどんどん逼迫されます。後者を行わないとCacheがクリアされるまで(デフォルトで600秒)、Gemini Pro Visionを利用するモードとなります。

上記のプログラムには考慮漏れがあります。Gemini Pro Visionの仕様を改めて確認すると

画像とテキストを含むプロンプト全体で最大 4 MB

とあります。

Base64にエンコードした場合、元の画像サイズが3.5 MB程度であっても4 MBを超えてしまいます。最近のスマホは非常に画像サイズが大きいため、

return '申し訳ございません。Gemini Proの呼び出しで異常終了しました。';

が多発する可能性があります。そこで、ImgAppライブラリを利用しましょう。

ImgAppライブラリ

このライブラリはGASが苦手とする画像編集を補完するライブラリです。主に、画像のサイズ取得やリサイズ、トリミング等を行うことができます。

追加用スクリプトID

1T03nYHRho6XMWYcaumClcWr6ble65mAT8OLJqRFJ5lukPVogAN2NDl-y

外部ライブラリを追加する場合は、以前dayjsライブラリを追加した際の記事を参考にしてください。

使用するメソッド

メソッド名 使い方
getSize ImgApp.getSize(blob)
doResize ImgApp.doResize(fileId, width)

非常に便利なライブラリではありますが、getSizeの際はblobdoResizeの際はfileIdを指定ってのがちょっとめんどくさいなって思いもありつつ、特性をしっかりを把握して利用しましょう。

ImgAppライブラリを用いてGemini Pro Visionに渡すデータを編集

getGeminiProVisionAnswerTxtを修正
function getGeminiProVisionAnswerTxt(txt, imageid) {
  try {  
+    let convWidth = ImgApp.getSize(DriveApp.getFileById(imageid).getBlob()).width/5
+        , convImage = ImgApp.doResize(imageid, Math.round(convWidth));
    const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-pro-vision:generateContent?key=${GEMINI_API}`
          , payload = {
              'contents': [{
                'parts': [{
                  'text': txt 
                },
                {
                  'inlineData': {
                    'mimeType': 'image/png',
-                   'data': Utilities.base64Encode(DriveApp.getFileById(imageid).getBlob().getBytes())
+                   'data': Utilities.base64Encode(convImage.blob.getBytes()) 
                  }
                }]
              }]
            }
          , options = {
              'method': 'post',
              'contentType': 'application/json',
              'payload': JSON.stringify(payload)
            };

    const res = UrlFetchApp.fetch(url, options)
          , resJson = JSON.parse(res.getContentText());

    if (resJson && resJson.candidates && resJson.candidates.length > 0) {
      return resJson.candidates[0].content.parts[0].text;
    } else {
      return '申し訳ございません。お答えできません。';
    }
  } catch (ex) {
    return '申し訳ございません。Gemini Proの呼び出しで異常終了しました。';
  }
}

このようにImgAppライブラリを用いて画像のWidthを取得し、ひとまず1/5くらいに修正してみました。ただし、これでもあまりにも大きな元画像である場合は、4 MBを超えることはあると思います。そのため、一律でWidthを1000等に設定し、doResizeのみを使うのもありかもしれません。ここは、実際に相談Botを使いながらチューニングしていきましょう。

doResizeの引数で1行前に取得したconvWidthMath.round(convWidth)を用いたうえで使用しているのは、doResizeで設定できるWidthは整数:Integerのみだからです。

プログラム全文

Qiita017
const GEMINI_API    = '**第14回記事のSTEP.1のNo.5で取得したAPI keyを記載する**';
const REPLY_URL     = 'https://api.line.me/v2/bot/message/reply';
const LINEAPI_TOKEN = '**第15回記事のSTEP.1のNo.17で取得したチャネルアクセストークンを記載する**';
const OUT_DIR       = '**一時保存領域のフォルダID**';

const sCache = CacheService.getScriptCache();
/**
 * LINEのトークでメッセージが送信された際に起動するメソッド
 * @param {EventObject} e - イベントオブジェクト
 */
function doPost(e){
  // イベントデータはJSON形式となっているため、parseして取得
  const eventData = JSON.parse(e.postData.contents).events[0]
        , repToken = eventData.replyToken
        , msgType = eventData.message.type;
  // テキストメッセージのとき
  if (msgType=='text') {
    let uText = eventData.message.text
        , gemini;
    if (!sCache.get('image')) {
      gemini = getGeminiProAnswerTxt(uText);
    } else {
      gemini = getGeminiProVisionAnswerTxt(uText, sCache.get('image'));
      DriveApp.getFileById(sCache.get('image')).setTrashed(true);
      sCache.remove('image');
    }
    replyTxt(repToken, gemini);
    sCache.put('user', uText.slice(0, 10000));
    sCache.put('model', gemini.slice(0, 10000));

  // 画像メッセージのとき
  } else if (msgType=='image') {
    let imageId = getImageId4Create(eventData);
    sCache.put('image', imageId);
    replyTxt(repToken, '送信された画像について聞きたいことは何ですか?');
  }
}

/**
 * LINEのトークに送信されたメッセージをGemini Pro APIに渡して回答を得るメソッド
 * @param {String} txt - 送信されたメッセージ
 */
function getGeminiProAnswerTxt(txt) {
  let contentsStr = '';
  // キャッシュにuidに紐づく情報が存在した場合、情報には過去の質問文が入っているためそれを取得
  if (sCache.get('user')) {
    contentsStr += `{
      "role": "user",
      "parts": [{
        "text": ${JSON.stringify(sCache.get('user'))}
      }]
    },
    {
      "role": "model",
      "parts": [{
        "text": ${JSON.stringify(sCache.get('model'))}
      }]
    },`
  }
  contentsStr += `{
    "role": "user",
    "parts": [{
      "text": ${JSON.stringify(txt)}
    }]
  }`
  const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-pro:generateContent?key=${GEMINI_API}`
        , payload = {
            'contents': JSON.parse(`[${contentsStr}]`)
          }
        , options = {
            'method': 'post',
            'contentType': 'application/json',
            'payload': JSON.stringify(payload)
          };
  const res = UrlFetchApp.fetch(url, options)
        , resJson = JSON.parse(res.getContentText());

  if (resJson && resJson.candidates && resJson.candidates.length > 0) {
    return resJson.candidates[0].content.parts[0].text;
  } else {
    return '回答を取得できませんでした。';
  }
}

/**
 * LINEのトークで送信された画像をGoogleドライブに保存し、ファイルIDを返却するメソッド
 * @param {EventObject} e - イベントオブジェクト
 */
function getImageId4Create(e) {
  const url = 'https://api-data.line.me/v2/bot/message/' + e.message.id + '/content'
        , options = { 
            'method': 'get',
            'headers': {
              'Authorization': 'Bearer ' + LINEAPI_TOKEN,
            }
          };
  const data = UrlFetchApp.fetch(url, options)
        , imageData = data.getBlob().getAs('image/png').setName(Number(new Date()));
  return DriveApp.getFolderById(OUT_DIR).createFile(imageData).getId();
}

/**
 * LINEのトークに送信されたメッセージをGemini Pro Vision APIに渡して回答を得るメソッド
 * @param {String} txt - 送信されたメッセージ
 */
function getGeminiProVisionAnswerTxt(txt, imageid) {
  try {  
    let convWidth = ImgApp.getSize(DriveApp.getFileById(imageid).getBlob()).width/5
        , convImage = ImgApp.doResize(imageid, Math.round(convWidth));
    const url = `https://generativelanguage.googleapis.com/v1beta/models/gemini-pro-vision:generateContent?key=${GEMINI_API}`
          , payload = {
              'contents': [{
                'parts': [{
                  'text': txt 
                },
                {
                  'inlineData': {
                    'mimeType': 'image/png',
                    'data': Utilities.base64Encode(convImage.blob.getBytes())
                  }
                }]
              }]
            }
          , options = {
              'method': 'post',
              'contentType': 'application/json',
              'payload': JSON.stringify(payload)
            };

    const res = UrlFetchApp.fetch(url, options)
          , resJson = JSON.parse(res.getContentText());

    if (resJson && resJson.candidates && resJson.candidates.length > 0) {
      return resJson.candidates[0].content.parts[0].text;
    } else {
      return '申し訳ございません。お答えできません。';
    }
  } catch (ex) {
    return '申し訳ございません。Gemini Proの呼び出しで異常終了しました。';
  }
}

/**
 * LINEのトークにメッセージを返却するメソッド
 * @param {String} token - メッセージ返却用のtoken
 * @param {String} text - 返却テキスト
 */
function replyTxt(token, txt){
  const message = {
                    'replyToken' : token,
                    'messages' : [{
                      'type': 'text',
                      'text': txt
                    }]
                  }
        , options = {
                    'method' : 'post',
                    'headers' : {
                      'Content-Type': 'application/json; charset=UTF-8',
                      'Authorization': 'Bearer ' + LINEAPI_TOKEN,
                    },
                    'payload' : JSON.stringify(message)
                  };
  UrlFetchApp.fetch(REPLY_URL, options);
}

プログラムの修正が終わったら、再デプロイしましょう。デプロイ方法を誤るとデプロイされたURLが変わってしまうので、必ずこちらを参考に行ってください。
※URLが変わるとLINE Developer側の設定も変更しなければなりません。

確認

…は、ぜひお手持ちのスマホで実施してみてください。お楽しみに!

おわりに

お疲れ様でした。
第17回は「GAS × Gemini Pro API × LINE Messaging API の相談Botで画像を扱う」ということで、これまで作成した相談Botをさらに進化させ、テキストだけでなく画像も扱えるようになりました。
どうでしょう、GASってやばくないですか!? ひとまず、今回で「あなただけの相談Bot」は完結です。次回以降はまたGoogleフォームに戻ろうと思います。引き続き、GASを楽しんでいきましょう!!
記事を読んで、「良いな」や「今後に期待できる!」と感じて頂けたらいいねフォローコメントいただけると幸いです。それではまた次回をお楽しみに!

ブログでより詳しく解説しています!

以下画像をクリックしてブログにアクセス!!

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