1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

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

Last updated at Posted at 2023-06-06

■ 実行環境

・Googleドライブ(ストレージ、無償)
・Google App Script(ホスト、無償)

■ 使用するライブラリ

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

モナリザの画像(左側)を送りますと、原画をベースにして新しい画像(右側)を生成してくれました。
モナリザ_and_ai.png

生成された画像をさらに投げかけて、10回繰り返しますと、次の結果になりました。
※図中の番号は順番です。
モナリザズ.png

■ GASソース

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

//各サービスのトークン情報(プロパティ設定)
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();
}
1
0
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
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?