■ 実行環境
・Googleドライブ(ストレージ、無償)
・Google App Script(ホスト、無償)
■ 使用するライブラリ
・ImgApp (画像加工)
※検索すれば、ドキュメントがたくさんありますので、ご参考ください。
・DriveApp(ファイル生成など、ドライブアクセス)
■ 構築手順概要
※各STEPの詳しい手順について、インターネット上、ドキュメントが
たくさん存在しますので検索してみてください。
- OpenAIに登録してAPI Keyを取得してください。
- LINEオフィシャルサイトからLINEボットアカウントを登録してください。
⇒ LINEアクセストークンを取得してください。 - GMAILでログインして、Googleのドライブホーム画面から
ドライブ⇒マイドライブ⇒その他⇒Google App Scriptをクリックしますと、
Apps Scriptホーム画面が表示されます。 - 画面左側の歯車アイコンをクリックして、出てくる設定画面の後ろ部分に
「スクリプトプロパティを追加」ボタンがありますので、クリックして
下記プロパティを登録してください
・MY_OPENAI_APIKEY = <上記(1)で取得OPENAI API KEY>
・LINE-TOKEN = <上記(2)で取得したLINEアクセストークン> - Apps Scriptのライブラリ+でImgAppを追加してください。
- gasソース編集画面「<>」に、下記添付ソースを適当に修正して、コピペしてください。
- 画面右上の「デプロイ」プルダウンメニューから「新しいデプロイ」でソースをデプロイしてください
- デプロイ成功しますと、「デプロイを管理」ポップアップダイアログが表示されますので、中の
ウェブアプリURLを上記(2)LINEのWebhookアドレスに登録してください。 - ここまで準備完了です。途中出てくるセキュリティ確認について、
適当に「はい」などにして設定してください - LINEクライアントからメッセージを送って、ChatGPTが返事してくれれば完成です。
試しに、「富士山の絵を描いてね」と声をかけますと、次の絵を生成してくれました。
モナリザの画像(左側)を送りますと、原画をベースにして新しい画像(右側)を生成してくれました。
生成された画像をさらに投げかけて、10回繰り返しますと、次の結果になりました。
※図中の番号は順番です。
■ 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();
}