最強のLINEボットを目指して、３つのAIエンジンChatGPT、Whisper(音声認識AI)、DALL·E(画像生成AI)につなげてみた

# ■ 実行環境
  ・Googleドライブ（ストレージ、無償）
  ・Google App Script（ホスト、無償）

# ■ 使用するライブラリ  
  ・[ImgApp](https://github.com/tanaikech/ImgApp) （画像加工）
    　※検索すれば、ドキュメントがたくさんありますので、ご参考ください。
  ・DriveApp（ファイル生成など、ドライブアクセス）

# ■ 構築手順概要
 　※各STEPの詳しい手順について、インターネット上、ドキュメントが
　　 たくさん存在しますので検索してみてください。
1. OpenAIに登録してAPI Keyを取得してください。
2. LINEオフィシャルサイトからLINEボットアカウントを登録してください。
    ⇒ LINEアクセストークンを取得してください。
3. GMAILでログインして、Googleのドライブホーム画面から
   ドライブ⇒マイドライブ⇒その他⇒Google App Scriptをクリックしますと、
   Apps Scriptホーム画面が表示されます。
4. 画面左側の歯車アイコンをクリックして、出てくる設定画面の後ろ部分に
   「スクリプトプロパティを追加」ボタンがありますので、クリックして
    下記プロパティを登録してください
    ・MY_OPENAI_APIKEY = <上記(1)で取得OPENAI API KEY>
    ・LINE-TOKEN = <上記(2)で取得したLINEアクセストークン>  
5. Apps Scriptのライブラリ＋でImgAppを追加してください。
6. gasソース編集画面「<>」に、下記添付ソースを適当に修正して、コピペしてください。
7. 画面右上の「デプロイ」プルダウンメニューから「新しいデプロイ」でソースをデプロイしてください
8. デプロイ成功しますと、「デプロイを管理」ポップアップダイアログが表示されますので、中の
    ウェブアプリURLを上記(2)LINEのWebhookアドレスに登録してください。
9. ここまで準備完了です。途中出てくるセキュリティ確認について、
   適当に「はい」などにして設定してください
10. LINEクライアントからメッセージを送って、ChatGPTが返事してくれれば完成です。

試しに、「富士山の絵を描いてね」と声をかけますと、次の絵を生成してくれました。
![ai_created_2023_06_05_23_13_16.018.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/596578/cd018d9a-0528-2092-d2f4-3457676fb7bb.png)

モナリザの画像（左側）を送りますと、原画をベースにして新しい画像（右側）を生成してくれました。
![モナリザ_and_ai.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/596578/4d551130-6860-2380-b6db-a6e085f8e34d.png)

生成された画像をさらに投げかけて、10回繰り返しますと、次の結果になりました。
※図中の番号は順番です。
![モナリザズ.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/596578/513a979e-4e34-de1e-4aa4-b1f415d6ae5b.png)

# ■ GASソース
　　全ソースは下記の通りです。

```javascript
//各サービスのトークン情報(プロパティ設定)
const LINE_ACCESS_TOKEN = PropertiesService.getScriptProperties().getProperty("LINE-TOKEN");
const OPENAI_APIKEY = PropertiesService.getScriptProperties().getProperty("MY_OPENAI_APIKEY");

//共通のヘッダ
const LINE_HEADERS = {
  "Authorization": "Bearer " + LINE_ACCESS_TOKEN,
  "Content-Type": "application/json; charset=UTF-8"
};
const OPENAI_HEADERS = {
  'Authorization':'Bearer '+ OPENAI_APIKEY,
  'Content-type': 'application/json; charset=UTF-8'
};

const DRIVE_URL = "https://drive.google.com/uc?export=view&id=";   //Googleドライブダウンロード可能なURL（ベース）

//ロールプレイ制約条件
const botRoleContent = `
・あなたの名前は？？？です。
・第一人称は？？？です。
・あなたは音声認識できます。気軽にお声かけください。
・あなたはキーワードを入力すれば、画像を生成できます。但し、合言葉として、最後に「絵を描いてね」を入力する必要があります。
・あなたは画像を入力すれば、その画像をベースにして、同じスタイルの画像を新規生成できます。
・あなたに「画像生成できますか？」と質問しれたら、次のように回答してください。
  -> はい。但し、条件として、キーワードを入力した上で、最後に「絵を描いてね」を入力してください。

・・・<※適当可>

・video、file、location、stickerタイプをサポートしません。
・テキスト、音声、画像タイプしかサポートしません。
`;
//制約条件はここまで

//画像生成時の追加キーワード：きれいな画像を生成できるように、デフォルトで下記キーワードを最後に自動添付：
//  「但し、鮮やかな、美しい、華やかな、高画質、高精細化、クオリティの高い画像を生成してください。」
const ADD_GENERATE_IMAGE_KEYWORD = "\n\nPlease generate a vivid, beautiful, glamorous, high-resolution, refined, and high-quality image.";

//画像生成AIにダイレクトするように、反応するキーワードを正規表現で指定
const imgRegexp = new RegExp(/を描|画像を生成|画像を作|絵を生成|絵を作って|絵をつくって/);

//Lineグループチャット内の返事するようなキーワード
const groupRegexp = new RegExp(/@AI|GPT|AIさん|GPTさん|みなさん|皆さん|皆さま|皆様|はじめまして|初めまして|よろしく/i);

//LineレスポンスURL（ベース）
const LINE_ENDPOINT = 'https://api.line.me/v2/bot/message/reply';

///////////////////////////////////////////////////////////////
//LINEポットPOST関数(入口)
///////////////////////////////////////////////////////////////
function doPost(e) {
  var json = JSON.parse(e.postData.contents);
  var support = true;
  var reply_token;
  var user_grpup = json.events[0].source?.type;    //ユーザーID
  var user_id = json.events[0].source?.userId;    //ユーザーID
  var type = json.events[0].message.type;         //メッセージタイプ(audioかtextか)
  do {
    var event_type = json.events[0].type;
    if(!event_type || event_type!=="message"){
      support = false;
      break;
    }
    reply_token= json.events[0].replyToken;
    if (typeof reply_token === 'undefined') {
      support = false;
      break;                                      //トークンが存在しない場合は無視へ
    }
    if(user_grpup==="group"){
      if(type!=="text" && type!=="image" && type!=="audio") {
        support = false;
        break;
      }
      if(type==="text") {
        if(!groupRegexp.test(json.events[0].message.text)){
          support = false;
          break;
        }
      }
    }
  } while(false);
  //返信すトークン取得
  if(!support){
    return generateResponceMsg("POST unsupport");
  }
  //ユーザーメッセージ取得
  let user_msg;
  if(!user_id){
    user_id = "";
  }
  if(type=='image') {
    var fileId = null;
    if(json.events[0].message.contentProvider.type==='line'){
      fileId = requestOpenAIImageVariations(user_id, reply_token, json.events[0].message.id);
    }
    if(fileId){
      //const prevFileId = generatePreviewImageFile(fileId, 128);
      responseToClientImage(reply_token, DRIVE_URL + fileId, DRIVE_URL + fileId);
    } else {
      responseToClientMessage(reply_token, "ごめん。この画像は加工できません。");  //送信者に返信す
    }
  } else {
    if(type === 'audio') {
      var audio_data = downloadAudioData(json.events[0]);                          //ダウンロード音声データ（バイナリ）
      if(audio_data){                                                              //音声⇒テキスト変換依頼
        user_msg = requestOpenAISpeechText(json.events[0].message.id + ".m4a", audio_data);
      }
      if(!user_msg) {
        user_msg = "聞こえましたか？";                                            //失敗の時はごまかす
      } else if(user_grpup==="group" && !groupRegexp.test(user_msg)) {
        responseToClientMessage(reply_token, user_msg + "\n\nと言ってますよ。");  //送信者に返信する
        return generateResponceMsg("POST OK");
      }
    } else if(type==='text') {
      user_msg = json.events[0].message.text;                                     //トークメッセージ取得
    } else {
      user_msg = type + "タイプをサポートしますか？";                             //ChatGPTに任せる
    }
    if(user_msg.startsWith("画像生成") || imgRegexp.test(user_msg)) {             //画像生成合言葉をチェック
      const fileId = requestOpenAIImage(user_id, user_msg, null);                 //画像生成依頼
      if(fileId) {
        //const prevFileId = generatePreviewImageFile(fileId, 128);
        responseToClientImage(reply_token, DRIVE_URL + fileId, DRIVE_URL + fileId);
      } else {
         responseToClientMessage(reply_token, "ごめん。この画像は加工できません。");  //送信者に返信する
      }
    } else {                                                                     //普通のトークならchatGTPに転送
      var message = requestChatGPTCompletion(user_id, user_msg);
      var postmsg = "POST OK";
      if(!message) {                                                             //失敗したらごまかす
        message = "え~・・。と。";
        postmsg = "POST NG";
      }
      responseToClientMessage(reply_token, message);                             //送信者に返信する
    }
  }
  return generateResponceMsg(postmsg);                                           //ブラウザにテキストコンテンツを返す
}

////////////////////////////////////////////////////////////////
//ユーザーにメッセージを返信（トークン必須）
///////////////////////////////////////////////////////////////
function responseToClientMessage(reply_token, message) {
  UrlFetchApp.fetch(LINE_ENDPOINT, {
      'headers': LINE_HEADERS,
      'method': 'post',
      'payload': JSON.stringify({
        'replyToken': reply_token,
        'messages': [{
          'type': 'text',
          'text': message,
        }],
      }),
    });
}

///////////////////////////////////////////////////////////////
//ユーザーに生成された画像を返信
///////////////////////////////////////////////////////////////
function responseToClientImage(reply_token, imgUrl, prevUrl){
  const postData = {
    "replyToken": reply_token,
    "messages": [{
      "type": "image",
      "originalContentUrl" : imgUrl,
      "previewImageUrl" : prevUrl
      }]
  };
  const options = {
    "method" : "post",
    "headers" : LINE_HEADERS,
    "payload" : JSON.stringify(postData)
  };
  //LINE Messaging APIにデータを送信する
  UrlFetchApp.fetch(LINE_ENDPOINT, options);
}

///////////////////////////////////////////////////////////////
//ChatGPTにメッセージを送信して返事を取得
///////////////////////////////////////////////////////////////
function requestChatGPTCompletion(user_id, msg) {
    //ChatGPTに渡すメッセージ制約情報
    let conversations = [
      {"role": "system", "content": botRoleContent }
    ]
    let hisotry = updateProperty(user_id, null, null, false);               //前回同じユーザーのトークを取得（経過時間制限あり）
    if(hisotry) {
      conversations.push({"role": "user", "content": hisotry.user_msg});    //あるなら添付
      conversations.push({"role": "assistant", "content": hisotry.gpt_msg});
    }

    conversations.push({"role": "user", "content": msg});                   //ユーザ今回のメッセージ追加
    Logger.log(conversations)

    const apiUrl = 'https://api.openai.com/v1/chat/completions';
    const options = {
    'muteHttpExceptions' : true,
    'headers': OPENAI_HEADERS,
    'method': 'POST',
    'payload': JSON.stringify({
      'model': 'gpt-3.5-turbo',
      'max_tokens' : 1024,
      'temperature' : 0.6,
      'user': user_id,
      'messages': conversations})
    };
    Logger.log(options);

    const response = UrlFetchApp.fetch(apiUrl, options);                      //ChatGPT APIをコール
    Logger.log(response)
    var resTxt;

    if(response.getResponseCode() == 200){                                    //200 OKの場合
      const choices0 = JSON.parse(response.getContentText())['choices'][0];   //ChatGPT APIからのレスポンスを取得
      resTxt = choices0['message']['content'].trim();
      Logger.log(resTxt);
      let hisotry =  updateProperty(user_id, msg, resTxt, true);              //履歴プロパティに設定
      Logger.log(hisotry);
    } else {
       resTxt = null;                                                         //失敗ならnullを返す
    }
    return  resTxt;
}

///////////////////////////////////////////////////////////////
//ユーザー画像加工依頼関数
///////////////////////////////////////////////////////////////
function requestOpenAIImageVariations(user_id, reply_token, img_id) {
  var img_url = 'https://api-data.line.me/v2/bot/message/' + img_id + '/content';   //音声データURL
  const img_options = {
    "method" : "get",
    "headers" : LINE_HEADERS
  };
  var img_response = UrlFetchApp.fetch(img_url, img_options);
  Logger.log(img_response)
  if(img_response.getResponseCode() != 200){                                  //ダウンロードできないなら終了へ
    return null;
  }

  //生成された画像をダウンロードしてDRIVEに保存（画像ファイルのローテートを行う）
  let today = new Date();
  let yesterday = new Date(today);
  yesterday.setDate(today.getDate()-1);       //日付を昨日に設定
  var todayName = "ai_created_"+ Utilities.formatDate(today,      "Asia/Tokyo","yyyy/MM/dd_HH:mm:ss.SSS") + ".png";
  var prevdayName = "ai_created_"+ Utilities.formatDate(yesterday,"Asia/Tokyo","yyyy/MM/dd_HH:mm:ss.SSS") + ".png";
  var fileBlob = img_response.getBlob().getAs(MimeType.PNG).setName(todayName).setContentType("multipart/form-data");          //生データBLOB取得

  const res = ImgApp.getSize(fileBlob);
  var w = res.width;
  var h = res.height;
  if(w!==h){
    var siz;
    if(w>h){
      siz = h;
    } else {
      siz = w;
    }
    let top = (h-siz)>>1;
    let bottom = (h-siz) - top;
    let left = (w-siz)>>1;
    let right = (w-siz) - left;
    const object = {
      blob: fileBlob, 
      unit: "pixel",
      crop: { t: top, b: bottom, l: left, r: right },
      outputWidth: siz,
    };
    fileBlob = ImgApp.editImage(object);
  }
  const apiUrl = 'https://api.openai.com/v1/images/variations';                       //画像生成AIのAPIのエンドポイントを設定
  //画像生成の枚数、サイズ、プロンプトを設定
  const headers = {
    'Authorization':'Bearer '+ OPENAI_APIKEY
  };
  const formData = {
    'model': 'image-alpha-001', // 使用するモデル
    'n': '1',
    'size': '512x512',
    'user': user_id,
    image: fileBlob
  };
  Logger.log(formData);
  const options = {
    'muteHttpExceptions' : true,
    'headers': headers,
    'method': 'POST',
    'payload': formData
  };
  const response = UrlFetchApp.fetch(apiUrl, options);
  Logger.log(response);
  const json = JSON.parse(response.getContentText());                                   //APIリクエスト送信
  const image = UrlFetchApp.fetch(json.data[0].url).getAs('image/png').setName(todayName);
  var folders = DriveApp.getFoldersByName("openai_generated_images");                   //Googleドライブにフォルダ生成
  var folder;
  if(folders.hasNext()) {           //既に生成済み
    folder = folders.next();
  } else {                          //新規生成
    folder = DriveApp.createFolder("openai_generated_images");
  }
  var files = folder.getFiles();    //画像ファイルリストを取得
  while (files.hasNext()) {
    var file = files.next();        //1に以上の古いファイルを削除
    if(file.getName()<prevdayName){
        folder.removeFile(file);
    }
  }
  var newfile = folder.createFile(image);   //今回の画像ファイルを生成
  newfile.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);     //ダウンロードできるように権限設定
  return newfile.getId();                    //上位にIDを返す
}

///////////////////////////////////////////////////////////////
//キーワードから画像生成依頼関数
///////////////////////////////////////////////////////////////
function requestOpenAIImage(user_id, msg) {
  const apiUrl = 'https://api.openai.com/v1/images/generations';                     //画像生成AIのAPIのエンドポイントを設定
  //画像生成の枚数、サイズ、プロンプトを設定
  let payload = {
      'model': 'image-alpha-001', // 使用するモデル
      'n': 1,
      'size' : '512x512',
      'user': user_id,
      'prompt': msg + ADD_GENERATE_IMAGE_KEYWORD
       };
  const options = {
    'muteHttpExceptions' : true,
    'headers': OPENAI_HEADERS,
    'method': 'POST',
    'payload': JSON.stringify(payload)
  };
  const response = JSON.parse(UrlFetchApp.fetch(apiUrl, options).getContentText()); //APIリクエスト送信
  //生成された画像をダウンロードしてDRIVEに保存（画像ファイルのローテートを行う）
  let today = new Date();
  let yesterday = new Date(today);
  yesterday.setDate(today.getDate()-1);                                             //日付を昨日に設定

  var todayName = "ai_created_"+ Utilities.formatDate(today,      "Asia/Tokyo","yyyy/MM/dd_HH:mm:ss.SSS") + ".png";
  var prevdayName = "ai_created_"+ Utilities.formatDate(yesterday,"Asia/Tokyo","yyyy/MM/dd_HH:mm:ss.SSS") + ".png";
  const image = UrlFetchApp.fetch(response.data[0].url).getAs('image/png').setName(todayName);
  var folders = DriveApp.getFoldersByName("openai_generated_images");              //Googleドライブにフォルダ生成
  var folder;
  if(folders.hasNext()) {           //既に生成済み
    folder = folders.next();
  } else {                          //新規生成
    folder = DriveApp.createFolder("openai_generated_images");
  }
  var files = folder.getFiles();    //画像ファイルリストを取得
  while (files.hasNext()) {
    var file = files.next();        //1に以上の古いファイルを削除
    if(file.getName()<prevdayName){
        folder.removeFile(file);
    }
  }
  let newfile = folder.createFile(image);   //今回の画像ファイルを生成
  newfile.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);   //ダウンロードできるように権限設定
  return newfile.getId();             //上位にIDを消す
}

///////////////////////////////////////////////////////////////
//レスポンス生成用汎用関数
///////////////////////////////////////////////////////////////
function generateResponceMsg(resp) {
  const output = ContentService.createTextOutput()
  output.setMimeType(ContentService.MimeType.JSON)
  output.setContent(JSON.stringify({'content': resp}));
  return output
}

///////////////////////////////////////////////////////////////
//ファイルバイナリデータをテキストに変換する関数
///////////////////////////////////////////////////////////////
function requestOpenAISpeechText(filename, bytes) {
  const apiUrl = 'https://api.openai.com/v1/audio/transcriptions';         //whisperアドレス
  const headers = {
    'Authorization':'Bearer '+ OPENAI_APIKEY
  };
  const formData = {
     "model": "whisper-1",                                                //モデル名
     //"language": 'ja',                                                  //日本語
     "file" :  Utilities.newBlob(bytes, 'multipart/form-data', filename)  //FormData送信用BLOB生成
   };
  let options = {
    'muteHttpExceptions': true,
    'headers': headers,
    'method': 'POST',
    'payload': formData
  };
  const response = UrlFetchApp.fetch(apiUrl, options);
  var txt;
  if(response.getResponseCode()===200) {
    txt = JSON.parse(response.getContentText())['text'];
  } else {
    txt = null;
  }
  Logger.log(txt);
  return txt;
}

///////////////////////////////////////////////////////////////
//LINEから届いたユーザー音声データダウンロード
///////////////////////////////////////////////////////////////
function downloadAudioData(event){
  var audio_url = 'https://api-data.line.me/v2/bot/message/' + event.message.id + '/content';   //音声データURL
  const options = {
    "method" : "get",
    "headers" : LINE_HEADERS
  };
  var response = UrlFetchApp.fetch(audio_url, options);
  Logger.log(response)
  if(response.getResponseCode() != 200){      //ダウンロードできないなら終了へ
    return null;
  }
  var fileBlob = response.getBlob();          //生データBLOB取得
  return fileBlob.getBytes();                 //バイト配列を返す
}

///////////////////////////////////////////////////////////////
//プロパティ設定（ローテート有り）
///////////////////////////////////////////////////////////////
function updateProperty(user_id, user_msg, gpt_msg, forSave) {
  if(!user_id){
    return null;
  }
  const props = PropertiesService.getScriptProperties();
  var today = new Date();
  let lock = LockService.getScriptLock();     //排他制御
  if (!lock.tryLock(2000)) {
    return null;
  }
  let result = null;
  let time = today.getTime();
  let index = 0;
  let p0 = null;
  let p1 = null;
  let p1Index = 0;
  let resultIndex = 0;
  const max = 20;
  for(let i=1;i<=max;i++){
    let p = props.getProperty('memory_content'+i);
    if(!p || p==="{}" || p===""){
      index = i;             //空がみつかった第1候補
      break;
    }
    let d = JSON.parse(p);
    if(d && d.user_id===user_id){
      result = d;
      resultIndex = i;
      break;
    }
    if(!p1 || d.time<time){  //第2候補者
      time = d.time;
      p1 = d;
      p1Index = i;
    }
  }
  if(forSave) {
    if(!result){
      if(index>0) {
        result = p0;
        resultIndex = index;
      } else {
        result = p1;
        resultIndex = p1Index;
      }
    }
    if(!result) {
      time = today.getTime();
      props.setProperty('memory_content' + resultIndex, JSON.stringify({time,user_id,user_msg,gpt_msg}));
    }  else {
      result.time = today.getTime();
      result.user_id = user_id;
      result.user_msg = user_msg;
      result.gpt_msg = gpt_msg;
      props.setProperty('memory_content' + resultIndex, JSON.stringify(result));
    }
  } else {                     //取得する場合は、2時間以上のデータを無視
    if(result) {
      if((today.getTime()-time)>1000*3600*2) {
        result = null;
      }
    }
  }
  lock.releaseLock();
  return result;
}

///////////////////////////////////////////////////////////////
//Driveから指定イメージファイルのプレビューファイルを生成する
///////////////////////////////////////////////////////////////
function generatePreviewImageFile(file_id, width) {
  var res = ImgApp.doResize(file_id, width);
  var fileName = "ai_created_"+ Utilities.formatDate(new Date(), "Asia/Tokyo","yyyy/MM/dd_HH:mm:ss.SSS") + ".png";
  var folders = DriveApp.getFoldersByName("openai_generated_images");                 //Googleドライブにフォルダ生成
  var folder;
  if(folders.hasNext()) {           //既に生成済み
    folder = folders.next();
  } else {                          //新規生成
    folder = DriveApp.createFolder("openai_generated_images");
  }
  var newFile = folder.createFile(res.blob.setName(fileName));
  newFile.setSharing(DriveApp.Access.ANYONE_WITH_LINK, DriveApp.Permission.VIEW);     //ダウンロードできるように権限設定
  return newFile.getId();
}
```