4
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GASで日記BOTを作ってみる

Posted at

お疲れ様です、みやもとです。

最近書く記事がLINEBOTばっかりになってきました。
手軽に組んで動かせるので勉強ついでにちょっと作ると楽しいです。

今回も懲りずにLINEBOTです。
ただし初めてのGoogle Apps Scriptです。

日記BOTを作ろう

今回作ったのは短い日記をつけるBOTです。
せっかくだしちょっとはAIも使いたいなー、ということで英語日記を添削してもらうことにしました。

そこそこ昔に頑張って英会話とか通ってたこともあったんですが、ものすごい地域色の濃い英語に染まって以来くじけたので現在の私の英語力は割と残念なことになってます。
それでも最近はいろんなイベント参加するようになっていろいろやる気が出てきて、英語ももう一度やってみる意欲がわいてきました。
長いこと放置していたのでちょっとリハビリもかねて、このくらいからスタートしようかなと。

思いのほか長くなったので、今回の記事では
・LINE Messaging APIのチャネル作成および設定方法
・Gemini APIキーの作成方法
・GASのデプロイ方法
等の詳細には触れません。
参考資料のどの記事にあるかを記載しているのでそちらを参照してください。

BOTの概要

とりあえずやりたいのは

  • 原文の日本語と英訳した内容を比較して添削してもらう
  • 送信内容を記録として出力する

という2点。
Pythonで作ってDBにつなげばいい話なのですが、うっかり設定とかいろいろ間違って課金がえらいことになると怖いのでGASでスプレッドシートに出力しようと思います。

参考資料

作るにあたって、以下の記事を参考にしました。

まずGASとGeminiで作るLINEBOTに関する大まかな内容はこちらの記事。
LINE Messaging APIの設定内容とかAPIキーの取得についてはひとつめの記事をご参照ください。

次に、GASでAPIキー等を直書きせず設定する方法。
記事内に出てくる定数の設定についてはこちら。

最後に、GASでスプレッドシートをDB代わりに使うやり方。
スプレッドシートの検索・記入に関してはほぼここからです。

スプレッドシートを用意する

今回はGASをスプレッドシートの拡張機能として使用するので、スプレッドシートのファイルを作ります。
適当な名前を付けて「log」「diary」「user」の3シートに項目をつけていきます。

まずlogシート。「日付」「ユーザーID」「ログ」の3項目を付けます。
なくても問題ないですが、一応バグの調査用にあった方がいいかなと。

次にメインとなるdiaryシート。
「日付」「キー」「原文」「英訳」「添削コメント」「状態」と項目を付けます。
最初は「状態」はなかったのですがかなり引っかかったのでコードでステータス管理するために後から付けました。

最後にuserシート。私しか使わない予定ですが一応。

コードを書く

スプレッドシートが用意できたらGASのコードをつけます。
いつもと同様に全体は折りたたんでメソッド概要だけ解説したいと思います。

全体
コード.gs
// スクリプト実行用の定数取得
let prop = PropertiesService.getScriptProperties().getProperties();

let GEMINI_API = prop.GEMINI_API,
  REPLY_URL = prop.REPLY_URL,
  LINEAPI_TOKEN = prop.LINEAPI_TOKEN,
  GEMINI_URL = prop.GEMINI_URL,
  PUSH_URL = prop.PUSH_URL;

// 短時間のキャッシュ保存
const sCache = CacheService.getScriptCache();

// AIに渡すプロンプト
const AI_PROMPT = `以下の指示に従って応答してください。理解したら"わかりました"と応答してください。
  1. あなたは英語を教える教師です。
  2. 応答時は500文字以内のテキストで応答してください。
  3. 相手から原文と英訳のセットを渡された場合、以下の内容を応答してください。
   ・与えられた英訳のどこを直すとより自然な文章になるか
   ・自分なら与えられた原文をどう英訳するか
  4. 上記4.の応答をする際、先述の2項目を1つの段落にまとめて応答してください。`

// スプレッドシートの取得
let SS = SpreadsheetApp.getActiveSpreadsheet();
let LogSheet = SS.getSheetByName("log");
let DiarySheet = SS.getSheetByName("diary");
let UserSheet = SS.getSheetByName("user");

/**
 * 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 id = eventData.source.userId
    // ユーザー情報取得(初回は追加)
    getUser(id);
    // 日記データの取得
    let date = new Date();
    let timeString = Utilities.formatDate(date, 'JST', 'HH:mm:ss');
    let dateString = Utilities.formatDate(date, 'JST', 'yyyy/MM/dd');
    let yesterday = false;
    let replyTxt = '';

    // 時間帯によって日付を変更
    if (timeString <= '12:00:00') {
      // お昼以前であれば前日日付を基準にする
      date.setDate(date.getDate()-1)
      dateString = Utilities.formatDate(date, 'JST', 'yyyy/MM/dd');
      yesterday = true;
    }

    let dataIndex = getIndex(dateString + id);

    if (dataIndex < 0) {
      // 基準日の日記データが存在しない場合、出来事を質問する
      replyTxt = yesterday ? '昨日' : '今日';
      replyTxt = replyTxt +  'はどんなことがありましたか?';
      // 時間帯によって挨拶を変更
      if (timeString >= '5:00:00' && timeString < '11:00:00') {
        replyTxt = 'Good morning!\n' + replyTxt;
      } else if (timeString >= '11:00:00' && timeString < '18:00:00') {
        replyTxt = 'Good afternoon.\n' + replyTxt;
      } else {
        replyTxt = 'Good evening.\n' + replyTxt;
      }
      // 日記データを作成する
      createDiary([dateString, dateString + id, '', '', '',0]);
      appendLog(id, '日記データを新規作成');
    } else {
      // 基準日の日記データが存在する場合、データ内容をチェック
      let data = getTodaysDiary(dataIndex);
      if (data[5] == 0) {
        // 今日の出来事が書かれていない場合、今日の出来事を更新
        updateDiary([dateString, dateString + id, eventData.message.text, '', '',1]);
        appendLog(id, '日記データの原文更新');
        // 英訳を促す
        replyTxt = 'OK.\n次は英語で教えてください.';
      } else if (data[5] == 1) {
        // 英訳が書かれていない場合、英訳を更新
        updateDiary([dateString, dateString + id, data[2], eventData.message.text, '',2]);
        appendLog(id, '日記データの英訳更新');
        // AIで添削
        replyTxt = getGeminiAnswerText('原文:「' + data[2] + '」 英訳:「' + eventData.message.text +'');
        replyTxt = replyTxt;
        // 添削内容を更新
        updateDiary([dateString, dateString + id, data[2], eventData.message.text, replyTxt,9]);
        appendLog(id, '日記データの添削更新');
      } else {
        // どれにも該当しない場合はAIと会話
        replyTxt = getGeminiAnswerText(eventData.message.text);
        appendLog(id, 'AI応答');
      }
    }
    
    // メッセージを返す
    replyText(repToken, replyTxt);
    // メッセージをキャッシュに設定
    sCache.put('user', eventData.message.text.slice(0, 1000));
    sCache.put('model', replyTxt.slice(0, 1000));
  }
}

/**
 * LINEのトークに送信されたメッセージをGemini Pro APIに渡して回答を得るメソッド
 * @param {String} txt - 送信するメッセージ
 */
function getGeminiAnswerText(txt) {
  let contentsStr = '';
  // プロンプトを渡す
  contentsStr += `{
    "role": "user",
    "parts": [{ 
      "text": ${JSON.stringify(AI_PROMPT)}
    }]
  },
  {
    "role": "model",
    "parts": [{
      "text": ${JSON.stringify('わかりました')}
    }]
  },`;
  // キャッシュに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 = GEMINI_URL + 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 removeMarks(resJson.candidates[0].content.parts[0].text);
  } else {
    return '回答を取得できませんでした。';
  }
}

/**
 * Geminiから返されたテキストの装飾を除去するメソッド
 * @param {String} gemini_txt - 返却テキスト
 */
function removeMarks(gemini_txt) {
  txt = gemini_txt.replace(/\#+/g, ""); // ヘッダー
  txt = txt.replace(/\*\*+/g, ""); // 強調
  return txt;
}

/**
 * LINEのトークにメッセージを返却するメソッド
 * @param {String} token - メッセージ返却用のtoken
 * @param {String} txt - 返却テキスト
 */
function replyText(token, txt){
  let 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);
}

/**
 * LINEのトークに通知メッセージを送るメソッド
 */
function sendReminder(){
  // 日記データ有無判定用のキー
  let date = new Date();
  let dateString = Utilities.formatDate(date, 'JST', 'yyyy/MM/dd');

  // ユーザー情報取得
  let userInfo = getAllUser();

  for(var i = 0; i < userInfo.length; i++) {
    var id = userInfo[i][0];
    // 当日の日記有無を判定
    let dataIndex = getIndex(dateString + id);
    if (dataIndex < 0) {
      // 基準日の日記データが存在しない場合、リマインダーを送る
      let payload = {
            "to":id,
            "messages":[{
            "type":"text",
            "text":"日記はもう書きましたか?",
            }]
      };
      try{
        UrlFetchApp.fetch(PUSH_URL,{
        "method":"post",
        "contentType":"application/json",
        "headers":{
          "Authorization":"Bearer "+ LINEAPI_TOKEN,
        },  
        "payload": JSON.stringify(payload),
        });
      }catch(e){
        result = "エラーの内容:" + e;
      }
    }
  }
}

/**
 * ログを書き込むメソッド
 * @param {String} id - ユーザーID
 * @param {String} message - ログ内容
 */
function appendLog(id, message){
  // 今の時間を取得
  let date = new Date();
  let dateString = Utilities.formatDate(date, "JST", "yyyy/MM/dd HH:mm:ss");
  
  // 書き込み用データの作成
  let createData = [dateString,id,message];
  // 書き込み
  LogSheet.appendRow(createData);
}

/**
 * 指定したユーザーデータの有無を判定し、ない場合は追加するメソッド
 * @param {String} id - ユーザーID
 */
function getUser(id){
  // 最終行の取得
  let lastRow = UserSheet.getLastRow();
  // getRangeでは0を指定することができないのでデータが存在しないことになる
  if(lastRow <= 1) {
    // データが存在しない場合は追加
    UserSheet.appendRow([id]);
    return;
  }
}

/**
 * ユーザーデータを全件取得するメソッド
 */
function getAllUser(){
  // 最終行の取得
  let lastRow = UserSheet.getLastRow();
  // getRangeでは0を指定することができないのでデータが存在しないことになる
  if(lastRow <= 1) return;
  return UserSheet.getRange(2,1,lastRow-1, 1).getValues();
}

/**
 * 日記データの位置を取得するメソッド
 * @param {String} id - データキー(日付+ID)
 */
function getIndex(id){
  // 最終行の取得
  let lastRow = DiarySheet.getLastRow();
  // getRangeでは0を指定することができないのでデータが存在しないことになる
  if(lastRow <= 1) return -1;
  // データの取得
  let datas = DiarySheet.getRange(2,1,lastRow-1, 6).getValues();
  // データの検索
  let dataIndex = datas.findIndex((value) =>{
    return value[1] == id
  })
  return dataIndex;
}

/**
 * 日記データを読み込むメソッド
 * @param {Number} dataIndex - データ位置
 */
function getTodaysDiary(dataIndex){
  // データの取得
  let date = DiarySheet.getRange(dataIndex+2,1,1,1).getValue();
  let id = DiarySheet.getRange(dataIndex+2,2,1,1).getValue();
  let sentence = DiarySheet.getRange(dataIndex+2,3,1,1).getValue();
  let translated = DiarySheet.getRange(dataIndex+2,4,1,1).getValue();
  let comment = DiarySheet.getRange(dataIndex+2,5,1,1).getValue();
  let status = DiarySheet.getRange(dataIndex+2,6,1,1).getValue();
  let data = [date, id, sentence, translated, comment, status];

  return data;
}

/**
 * 日記データを作成するメソッド
 * @param {Array} addData - 日記に書き込む内容
 */
function createDiary(addData){
  // 書き込み
  DiarySheet.appendRow(addData);
}

/**
 * 日記データを更新するメソッド
 * @param {Array} updateData - 日記を更新する内容
 */
function updateDiary(updateData){
  // 情報の展開
  let [date,id,sentence,translated,comment,status] = updateData;
  // データの検索
  let dataIndex = getIndex(id);
  // データがマッチしない場合は除外
  if (dataIndex < 0) return
  // データアップデート
  DiarySheet.getRange(dataIndex+2,1,1,6).setValues([updateData]);
}

定数とかの定義

まず冒頭の定数等の定義から。

コード.gs
// スクリプト実行用の定数取得
let prop = PropertiesService.getScriptProperties().getProperties();

let GEMINI_API = prop.GEMINI_API,
  REPLY_URL = prop.REPLY_URL,
  LINEAPI_TOKEN = prop.LINEAPI_TOKEN,
  GEMINI_URL = prop.GEMINI_URL,
  PUSH_URL = prop.PUSH_URL;

// 短時間のキャッシュ保存
const sCache = CacheService.getScriptCache();

// AIに渡すプロンプト
const AI_PROMPT = `以下の指示に従って応答してください。理解したら"わかりました"と応答してください。
  1. あなたは英語を教える教師です。
  2. 応答時は500文字以内のテキストで応答してください。
  3. 相手から原文と英訳のセットを渡された場合、以下の内容を応答してください。
   ・与えられた英訳のどこを直すとより自然な文章になるか
   ・自分なら与えられた原文をどう英訳するか
  4. 上記4.の応答をする際、先述の2項目を1つの段落にまとめて応答してください。`

// スプレッドシートの取得
let SS = SpreadsheetApp.getActiveSpreadsheet();
let LogSheet = SS.getSheetByName("log");
let DiarySheet = SS.getSheetByName("diary");
let UserSheet = SS.getSheetByName("user");

あっちこっち切り貼りしたことでconstだったりletだったり大文字揃えだったり小文字混じってたりしてるのは見逃してください。
定数だし全部const大文字でそろえた方が良かったかな。でもオブジェクトを定数で切るのなんか違和感ある…このへんの感覚は何が正しいのか未だよくわからない。

まず一番上はAPIキーとかURLとかの定義を呼び出している部分です。
直に書くと記事書く時にうっかり消し忘れてえらいことになりそうで怖かったので、プロジェクトのプロパティとして登録しました。
詳細はこちらをご参照ください。

CasheServiceはAIに渡す会話履歴用です。添削結果に気になるところがあった時とか質問を重ねられた方がいいなーと思ったので直前のやりとりを保存するためにつけました。

次にAIに渡すプロンプト。
カジュアルに話せるようにちょっとキャラ付けしようと思ったのですが、どうにもうまくいかず。
あれこれいじった割に思うような会話のやりとりができなかったので最低限の指示にしました。

最後に出力用のスプレッドシートの取得。
このコードはスプレッドシートの拡張機能として動かす前提のためgetActiveSpreadsheetで取得しています。

Postメソッド:冒頭部分

次に今回のメイン部分。メッセージを受けて応答する処理のコードは以下です。
(LINEBOTで共通と思われる箇所は省きますがご了承ください)

コード.gs
    let id = eventData.source.userId
    // ユーザー情報取得(初回は追加)
    getUser(id);
    // 日記データの取得
    let date = new Date();
    let timeString = Utilities.formatDate(date, 'JST', 'HH:mm:ss');
    let dateString = Utilities.formatDate(date, 'JST', 'yyyy/MM/dd');
    let yesterday = false;
    let replyTxt = '';

ユーザーIDを取得してgetUserというメソッドを実行しているのは既存ユーザーかどうかの判定です。

コード.gs
/**
 * 指定したユーザーデータの有無を判定し、ない場合は追加するメソッド
 * @param {String} id - ユーザーID
 */
function getUser(id){
  // 最終行の取得
  let lastRow = UserSheet.getLastRow();
  // getRangeでは0を指定することができないのでデータが存在しないことになる
  if(lastRow <= 1) {
    // データが存在しない場合は追加
    UserSheet.appendRow([id]);
    return;
  }
}

userシート上のIDを検索して、未登録の場合はIDを出力するようになっています。

Postメソッド:日記データ作成

次はメインの日記処理です。

コード.gs
    // 時間帯によって日付を変更
    if (timeString <= '12:00:00') {
      // お昼以前であれば前日日付を基準にする
      date.setDate(date.getDate()-1)
      dateString = Utilities.formatDate(date, 'JST', 'yyyy/MM/dd');
      yesterday = true;
    }

    let dataIndex = getIndex(dateString + id);

    if (dataIndex < 0) {
      // 基準日の日記データが存在しない場合、出来事を質問する
      replyTxt = yesterday ? '昨日' : '今日';
      replyTxt = replyTxt +  'はどんなことがありましたか?';
      // 時間帯によって挨拶を変更
      if (timeString >= '5:00:00' && timeString < '11:00:00') {
        replyTxt = 'Good morning!\n' + replyTxt;
      } else if (timeString >= '11:00:00' && timeString < '18:00:00') {
        replyTxt = 'Good afternoon.\n' + replyTxt;
      } else {
        replyTxt = 'Good evening.\n' + replyTxt;
      }
      // 日記データを作成する
      createDiary([dateString, dateString + id, '', '', '',0]);
      appendLog(id, '日記データを新規作成');
    } else {
      // 基準日の日記データが存在する場合、データ内容をチェック
      let data = getTodaysDiary(dataIndex);
      if (data[5] == 0) {
        // 今日の出来事が書かれていない場合、今日の出来事を更新
        updateDiary([dateString, dateString + id, eventData.message.text, '', '',1]);
        appendLog(id, '日記データの原文更新');
        // 英訳を促す
        replyTxt = 'OK.\n次は英語で教えてください.';
      } else if (data[5] == 1) {
        // 英訳が書かれていない場合、英訳を更新
        updateDiary([dateString, dateString + id, data[2], eventData.message.text, '',2]);
        appendLog(id, '日記データの英訳更新');
        // AIで添削
        replyTxt = getGeminiAnswerText('原文:「' + data[2] + '」 英訳:「' + eventData.message.text +'');
        replyTxt = replyTxt;
        // 添削内容を更新
        updateDiary([dateString, dateString + id, data[2], eventData.message.text, replyTxt,9]);
        appendLog(id, '日記データの添削更新');
      } else {
        // どれにも該当しない場合はAIと会話
        replyTxt = getGeminiAnswerText(eventData.message.text);
        appendLog(id, 'AI応答');
      }
    }

最初に基準日を判定しています。
日記を書いた・書いてないの判定のための日付ですが、システム日付にすると朝うっかりメッセージを送った時に始まったばかりの今日の出来事を聞かれてしまうので、メッセージを受信したのが

  • 12時以前なら前日基準
  • 12時より後なら当日基準

として、やりとりすることにしました。

基準日を設定した後のgetIndexはスプレッドシート上に指定したキーの行があるかどうか、ある場合は何行目にあるかを返すメソッドです。

コード.gs
function getIndex(id){
  // 最終行の取得
  let lastRow = DiarySheet.getLastRow();
  // getRangeでは0を指定することができないのでデータが存在しないことになる
  if(lastRow <= 1) return -1;
  // データの取得
  let datas = DiarySheet.getRange(2,1,lastRow-1, 6).getValues();
  // データの検索
  let dataIndex = datas.findIndex((value) =>{
    return value[1] == id
  })
  return dataIndex;
}

キーは日付文字列+IDで設定しています。
最初は項目を分けて出力していましたが、検索動作がよくわからんことになったので1項目で判定可能な形にしました。
仕様として1項目しか指定できないのでしょうか、このへんの動作は日を改めて確認したいところです。

データがなかった場合は基準日の出来事を質問するメッセージを作成します。
挨拶を時間ごとに変えていますが、英語にしているのは最初のキャラ付けの名残ですね。
最初はあいさつとかちょっとした決まり文句は英語で返してというプロンプトを入れていたのですが、いまいち聞いてもらえなかったのでプロンプトからは消しました。

また、このタイミングで基準日の日記データを作成しています。
日記データの作成メソッドはこちら。

コード.gs
function createDiary(addData){
  // 書き込み
  DiarySheet.appendRow(addData);
}

実に簡素。appendRowで最終行に追加してくれるんですね。
ついでにログも同じような感じで出力しています。

コード.gs
function appendLog(id, message){
  // 今の時間を取得
  let date = new Date();
  let dateString = Utilities.formatDate(date, "JST", "yyyy/MM/dd HH:mm:ss");
  
  // 書き込み用データの作成
  let createData = [dateString,id,message];
  // 書き込み
  LogSheet.appendRow(createData);
}

で、すでに当日の日記データが存在する場合。
まずは日記データを取得します。コードはこんな感じ。

コード.gs
function getTodaysDiary(dataIndex){
  // データの取得
  let date = DiarySheet.getRange(dataIndex+2,1,1,1).getValue();
  let id = DiarySheet.getRange(dataIndex+2,2,1,1).getValue();
  let sentence = DiarySheet.getRange(dataIndex+2,3,1,1).getValue();
  let translated = DiarySheet.getRange(dataIndex+2,4,1,1).getValue();
  let comment = DiarySheet.getRange(dataIndex+2,5,1,1).getValue();
  let status = DiarySheet.getRange(dataIndex+2,6,1,1).getValue();
  let data = [date, id, sentence, translated, comment, status];

  return data;
}

データの有無を判定するのに使ったIndexを渡して、その位置にある日記データを取得しています。
最初はわざわざ1項目ずつ取得するのではなくフィルターを使って一気に返そうとしたのですが、これもよくわからない動きをしたので面倒ですがひとつひとつの項目を取得して配列化する形になりました。
これもまた別の機会に検証します。

データを取得したら、行の最後にあるステータスの値で状態を判断します。
0なら今日の出来事を聞いて日記更新、1なら英訳を聞いてAI添削した内容を合わせて更新、それ以外なら送られてきたメッセージを受けて日記更新なしのAI応答。

まず、日記の更新メソッドは以下の通りです。

コード.gs
function updateDiary(updateData){
  // 情報の展開
  let [date,id,sentence,translated,comment,status] = updateData;
  // データの検索
  let dataIndex = getIndex(id);
  // データがマッチしない場合は除外
  if (dataIndex < 0) return
  // データアップデート
  DiarySheet.getRange(dataIndex+2,1,1,6).setValues([updateData]);
}

よく考えたら先にIndex取得しているのでそのまま渡して検索省いてもよかったですね。
最初は位置取得を更新処理でしか使わない予定だったので更新処理内に直書きしていたのですが、なんやかんやほかでも使うぞと切り出した後中途半端に残っていたことに今気づきました。
画面表示したデータを更新する際とか直前に最新を取得するのは仕事でよくあるので手癖もあるかもしれません。
ともあれ、見つけた位置(見出し行があるのとインデックスは0からになるので+2)の日記データ行を範囲選択して、パラメータで受け取った内容をsetValuesして更新です。

そして次にAI応答。

コード.gs
function getGeminiAnswerText(txt) {
  let contentsStr = '';
  // プロンプトを渡す
  contentsStr += `{
    "role": "user",
    "parts": [{ 
      "text": ${JSON.stringify(AI_PROMPT)}
    }]
  },
  {
    "role": "model",
    "parts": [{
      "text": ${JSON.stringify('わかりました')}
    }]
  },`;
  // キャッシュに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 = GEMINI_URL + 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 removeMarks(resJson.candidates[0].content.parts[0].text);
  } else {
    return '回答を取得できませんでした。';
  }
}

今まではPythonのライブラリ使っていたのでJSONデータを編集するのは初めてです。
こうして書くと少しはわかるような…いややっぱややこしいような。

メソッド冒頭で渡しているのは最初の定数部分で定義したプロンプトです。
プロンプトと「わかりました」のやりとりを履歴として渡すことで役割付けをしたら、次はキャッシュが残っていないかを確認しています。
参考記事としたこちらに出てきた会話の継続性というやつですね。
これがないと直前のAI応答に関連する質問をしても「今初めて聞きました」みたいな反応を返される可能性があるので大切です。
それが終わってようやくLINEで受信したメッセージ(添削の場合は日記の原文と訳文)を渡して応答をもらいます。

応答をもらったらあとはそのままLINEで返すだけ、にしたかったのですが、そのまま返すと結構な率でマークダウン形式のテキストが返されます。プレーンテキストで返してって指示つけても無視されることが多い。
なので、せめてアスタリスクの連続とかは取り除きたいなーとメソッド追加しました。

コード.gs
function removeMarks(gemini_txt) {
  txt = gemini_txt.replace(/\#+/g, ""); // ヘッダー
  txt = txt.replace(/\*\*+/g, ""); // 強調
  return txt;
}

ヘッダーはあんまりつかないけど強調は必ずと言っていいほど多発していたので、これを通した後のテキストを返信として設定。

Postメソッド:返信

ここまで終わってようやく返信です。

コード.gs
    // メッセージを返す
    replyText(repToken, replyTxt);
    // メッセージをキャッシュに設定
    sCache.put('user', eventData.message.text.slice(0, 1000));
    sCache.put('model', replyTxt.slice(0, 1000));

リプライメッセージ送信の後、受信したメッセージをuser側・返信したメッセージをmodel側としてキャッシュに残します。
GASで作るLINEBOTなら大抵は基本的に同じ内容と思いますが、リプライメッセージ送信のメソッドはこちら。

コード.gs
function replyText(token, txt){
  let 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);
}

リプライメッセージ送信するの1か所しかないのでメソッド切り出さなくてもよかったですね。
最初はif文の中にメッセージ返すとこまでつっこんでたので呼び出し部分が複数あったのですが、「最後にまとめて1回でええなこれ」と気づくのが遅かったためこうなりました。
もうちょっと落ち着いて考えて書け私。

動かしてみました

ともあれ、これを動かすとこんな感じになります。

私の残念な英語力は捨て置くとして、それなりちゃんと動いてるんじゃないでしょうか。

そして冒頭のメッセージ「日記はもう書きましたか?」に気づかれた方は鋭い。
コード全体を見て「足らんな?」と思われた方はよくぞそこまで読んでくださった。
Postメソッドの冒頭で出力したuserシートの内容を使ってリマインドをつけているのですが、ここまですでに結構書いたのでまた別途記事にしたいと思います。

4
5
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
5

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?