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

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

# シリーズの対象者
* そもそもGASってなんだか**わからない**方
* GASを学びたいけど**何から始めればいいか**わからない方
* GASはわかり始めたけど、**もっと活用ができないか**と模索している方
* とにかく**Googleが好き！** という方

# 前回記事
https://qiita.com/gas-suke/items/323ce91ded308470a48f

# 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は**とても優秀な相談相手**となるでしょう。

::: note info
**前回作成した相談Botを前提**に記事を書いておりますので、まだ相談Botを作成していない場合は、必ず<a href="https://qiita.com/gas-suke/items/323ce91ded308470a48f#%E3%83%97%E3%83%AD%E3%82%B0%E3%83%A9%E3%83%A0%E5%85%A8%E6%96%87" target="_blank">こちら</a>を参考にして作成してください。
:::

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

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

## STEP.1　LINEによる画像送信時のGoogleドライブへの一時保存
LINEでは画像とテキストを同時におくることはできません。そこで、
1. 画像を送信し、**Googleドライブに一時保存**する
1. 次にテキストを送信し、一時保存された画像データとともに**Gemini Pro Vision**に渡す

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

### 仕様の確認
#### 画像を含むメッセージオブジェクトに関するJSON

https://developers.line.biz/ja/reference/messaging-api/#wh-image

こちらを確認すると、`contentProvider.type`が`line`、つまり**ユーザーが画像ファイルを送信した場合**、
>画像ファイルのバイナリデータは、メッセージIDを指定してコンテンツを取得するエンドポイントを使用することで取得できます。

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

#### コンテンツを取得するエンドポイント
https://developers.line.biz/ja/reference/messaging-api/#get-content

```bash
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ドライブ上に画像ファイルを一時保存するため、**一時保存領域を作成**しておきましょう。
<img src = "https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3575702/777d536c-6a41-445f-6ed1-d3827c63241c.png" width="600">
※私は同一フォルダに`tmp`を作成しました。この**tmpのフォルダIDを確認**しておいてください。

```diff_javascript: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の仕様を確認しましょう。

https://ai.google.dev/tutorials/rest_quickstart#text-and-image_input

```bash
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`を指定し、`data`を**base64**で渡せば、Gemini Pro Visionは受け付けてくれるようです。

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

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

### 実装
それではやってみましょう。
```diff_javascript: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**に渡します。

::: note info
`getGeminiProVisionAnswerTxt`を呼び出したあとは、
1. 一時保存した画像を削除
1. `Cache`に設定した画像ファイルIDを削除

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

:::note warn
上記のプログラムには**考慮漏れ**があります。<a href="https://ai.google.dev/docs/gemini_api_overview?hl=ja#image_requirements" target="_blank">Gemini Pro Visionの仕様</a>を改めて確認すると

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

とあります。

Base64にエンコードした場合、元の**画像サイズが3.5 MB程度であっても4 MBを超え**てしまいます。最近のスマホは非常に画像サイズが大きいため、
```javascript
return '申し訳ございません。Gemini Proの呼び出しで異常終了しました。';
```
が多発する可能性があります。そこで、**ImgAppライブラリを利用**しましょう。
:::

### ImgAppライブラリ
このライブラリはGASが苦手とする画像編集を補完するライブラリです。主に、画像のサイズ取得やリサイズ、トリミング等を行うことができます。
#### 追加用スクリプトID
1T03nYHRho6XMWYcaumClcWr6ble65mAT8OLJqRFJ5lukPVogAN2NDl-y

:::note 
外部ライブラリを追加する場合は、以前<a href="https://qiita.com/gas-suke/items/887d45dabb2df3c5767a#step0dayjs%E3%83%A9%E3%82%A4%E3%83%96%E3%83%A9%E3%83%AA%E3%82%92%E8%BF%BD%E5%8A%A0%E3%81%99%E3%82%8B" target="_blank">dayjsライブラリを追加した際の記事</a>を参考にしてください。
:::

#### 使用するメソッド
| メソッド名 | 使い方 |
|:-:|:--|
| getSize | ImgApp.getSize(blob) |
| doResize | ImgApp.doResize(fileId, width) |

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

#### ImgAppライブラリを用いてGemini Pro Visionに渡すデータを編集
```diff_javascript: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を使いながらチューニング**していきましょう。

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

### プログラム全文
```javascript: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);
}
```

:::note info
プログラムの修正が終わったら、**再デプロイ**しましょう。デプロイ方法を誤るとデプロイされた**URLが変わってしまう**ので、必ず<a href="https://qiita.com/gas-suke/items/b9165944b432a58bc7a1#%E3%83%87%E3%83%97%E3%83%AD%E3%82%A4%E3%81%AE%E6%9B%B4%E6%96%B0" target="_blank">こちら</a>を参考に行ってください。
※URLが変わると**LINE Developer側の設定も変更**しなければなりません。
:::

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

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

## ブログでより詳しく解説しています！
以下画像をクリックしてブログにアクセス！！
<a href="https://gas-4u.com/gas16/" target="_blank"><img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3575702/436478bd-2dd4-20e6-7d8d-b472f9364dcb.jpeg" width="600"/></a>