当サイトにはアフィリエイト広告が含まれます。なおレビューは私の感想を書いており、内容を指示するご依頼はお断りしています

AI Botにタイムラグを:Google Apps Scriptで実現する『あえて即レスしない』人間らしい対話

導入:なぜAIに「人間らしさ」が必要なのか

近年、AI技術の進化は目覚ましく、LINE Botをはじめとするチャットボットは、私たちの生活に深く浸透しつつあります。しかし、その利便性の裏で、「AIらしい画一的な対応」や「いつでも即座に返信が来る」という特徴が、時にユーザーに物足りなさや機械的な印象を与えてしまうこともあります。

本ブログでは、私が開発中のLINE Botに「人間らしさ」という新しい価値を付与するために、Google Apps Script(GAS)を用いてどのようにアプローチしたかをご紹介します。常に完璧な対応をするのではなく、「間」や「余白」を意識した対話設計により、ユーザーにとってより魅力的で親しみやすいAIを目指します。


1. 予測不可能な「返信可能時刻」の生成:AIの“気まぐれ”を演出

AIとの対話において、常に即座に返信が来ることは、便利である反面、人間が相手ではないことを強く意識させる要因にもなり得ます。そこで、Botに「人間らしい生活リズム」を与えるため、ランダムな返信可能時刻を設定する機能を実装しました。

機能の概要と目的

  • 毎日0時頃に返信可能時刻のリストを自動でリセットします。
  • ユーザーにはこのリストは公開されません。
  • いつBotが返信してくれるか分からない状況を作り出し、ユーザーに「AIの返信を待つ時間」を提供します。これにより、返信が来た際の喜びや、相手(AI)への関心を高めます。
  • 特定の時間に縛られず、ランダムな「10分間」を5〜20セット生成し、その末尾(分・秒)もランダムにすることで、より自然で予測不可能な稼働時間を再現します。
function writeRandomTimeSlots() {
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = spreadsheet.getSheetByName("返信可能時刻"); // 応答時間を管理するシート名

  // 5〜20個のランダムな10分間隔の時間を生成
  const numSlots = Math.floor(Math.random() * (20 - 5 + 1)) + 5; 
  Logger.log(`生成する期間の数: ${numSlots}セット`);

  const data = [];

  for (let i = 0; i < numSlots; i++) {
    // 0〜23時の間、0〜59分の間で完全にランダムな開始時刻を生成
    const startHour = Math.floor(Math.random() * 24); 
    const startMinute = Math.floor(Math.random() * 60); 
    const startSecond = Math.floor(Math.random() * 60); 
    
    let startTime = new Date();
    const today = new Date(); // 今日の日付を設定
    startTime.setFullYear(today.getFullYear());
    startTime.setMonth(today.getMonth());
    startTime.setDate(today.getDate());
    startTime.setHours(startHour);
    startTime.setMinutes(startMinute);
    startTime.setSeconds(startSecond); 
    startTime.setMilliseconds(0);

    // 終了時刻は開始時刻から10分後
    let endTime = new Date(startTime.getTime() + 10 * 60 * 1000); 

    // シートに書き込むためのHH:mm形式にフォーマット
    const formattedStartTime = Utilities.formatDate(startTime, Session.getScriptTimeZone(), "HH:mm");
    const formattedEndTime = Utilities.formatDate(endTime, Session.getScriptTimeZone(), "HH:mm");
    
    data.push([formattedStartTime, formattedEndTime]);
  }

  // 既存のデータをクリアして新しいデータを書き込む
  if (sheet.getLastRow() > 1) { 
    sheet.getRange(2, 1, sheet.getLastRow() - 1, 2).clearContent(); 
  }
  if (data.length > 0) {
    sheet.getRange(2, 1, data.length, data[0].length).setValues(data);
  } else {
    Logger.log("生成された期間がないため、シートには何も書き込まれませんでした。");
  }
  Logger.log("ランダムな10分間の期間を書き込みました。");
}

2. 「対話の時間」を制御する:AIの“休息”と“稼働”を明確に

生成した返信可能時刻を実際にBotの動作に反映させるため、現在の時刻が設定された時間範囲内にあるかを判定する機能が必要です。これにより、Botは定められた時間以外には応答しないようになります。

機能の概要と目的

  • LINEからのメッセージ受信時、まず現在の時刻が「返信可能時刻」シートのいずれかの期間内にあるかをチェックします。
  • 時間外であれば、ChatGPTへの連携をスキップし、返信を行わず既読スルーになるようにします
  • AIが常に稼働しているわけではないという印象を与え、「AIも人間と同じように活動時間がある」という認識をユーザーに促します。


返信までに自然なタイムラグが発生するようになる。

/**
 * 現在時刻がスプレッドシートの「返信可能時刻」シートで指定された時間範囲内にあるかチェックする関数
 * @param {Date} checkTime チェックする時刻
 * @returns {boolean} 時間範囲内であれば true、そうでなければ false
 */
function checkReplyAvailability(checkTime) {
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = spreadsheet.getSheetByName("返信可能時刻"); 
  if (!sheet) {
    Logger.log("「返信可能時刻」シートが見つかりません。");
    return false; 
  }

  const range = sheet.getDataRange();
  const values = range.getValues(); 

  for (let i = 1; i < values.length; i++) {
    const startTimeValue = values[i][0]; 
    const endTimeValue = values[i][1];   

    if (!startTimeValue || !endTimeValue) continue; 

    let startHour, startMinute, endHour, endMinute;

    // 値の型をチェックし、適切に時分を取得
    if (typeof startTimeValue === 'string') {
      const startParts = startTimeValue.split(':');
      startHour = parseInt(startParts[0], 10);
      startMinute = parseInt(startParts[1], 10);
    } else if (startTimeValue instanceof Date) {
      startHour = startTimeValue.getHours();
      startMinute = startTimeValue.getMinutes();
    } else {
      Logger.log(`Unexpected type for startTimeValue: ${typeof startTimeValue}, value: ${startTimeValue}`);
      continue; 
    }

    if (typeof endTimeValue === 'string') {
      const endParts = endTimeValue.split(':');
      endHour = parseInt(endParts[0], 10);
      endMinute = parseInt(endParts[1], 10);
    } else if (endTimeValue instanceof Date) {
      endHour = endTimeValue.getHours();
      endMinute = endTimeValue.getMinutes();
    } else {
      Logger.log(`Unexpected type for endTimeValue: ${typeof endTimeValue}, value: ${endTimeValue}`);
      continue; 
    }

    let startDateTime = new Date(checkTime.getFullYear(), checkTime.getMonth(), checkTime.getDate(), startHour, startMinute, 0);
    let endDateTime = new Date(checkTime.getFullYear(), checkTime.getMonth(), checkTime.getDate(), endHour, endMinute, 0);

    // 日をまたぐ期間に対応
    if (endDateTime.getTime() < startDateTime.getTime()) {
      endDateTime.setDate(endDateTime.getDate() + 1);
    }
    
    if (checkTime.getTime() >= startDateTime.getTime() && checkTime.getTime() < endDateTime.getTime()) {
      Logger.log('返信可能時間内です。');
      return true; 
    }
  }
  Logger.log('返信可能時間外です。');
  return false; 
}

3. 「あとでまとめて返信」と「忘れかけた頃の呼びかけ」:AIの情緒的応答

人間同士のコミュニケーションでは、メッセージをまとめて返信したり、しばらく間が空いた後に連絡を取ったりすることが一般的です。これらの要素をAIに組み込むことで、より自然な対話を実現します。

機能の概要と目的

  • 未返信メッセージの取得とまとめて返信する機能
    • ChatLogシートから、一番新しいBotの返信(E列が空でない最終行)よりも新しい、かつD列(ユーザーメッセージ)が空でないメッセージを全て取得します。
    • ユーザーからのメッセージをリアルタイムで個別に返信するのではなく、ある程度のメッセージをまとめて処理することで、「後でまとめて返事する」という人間らしい行動を模倣します。
    • これにより、Botが常に監視しているわけではないという印象を与え、ユーザーに過度な期待をさせません。
  • AIが突然話しかける機能(6時間判定)
    • 特定のユーザーに対するBotの最終返信時刻をChatLogシートから確認し、それが現在時刻から指定した時間(例:6時間)以上経過している場合に、Bot側から能動的にメッセージを送信するトリガーとします。
    • これにより、「AIは話しかけない」という受動的な印象を打破しつつ、頻繁すぎない絶妙なタイミングでユーザーに接触を図ります。忘れかけた頃に連絡が来ることで、ユーザーはAIとの関係性をより「人間らしい」ものとして認識する可能性があります。
/**
 * ChatLogシートの最終行から上に遡り、E列(Reply列)が初めて空でなくなる行より上のD列(Message列)を取得する。
 * つまり、一番新しい返信より新しい、まだ返信されていないメッセージを全て取得する。
 * @returns {Array<string>} 未返信メッセージの配列
 */
function getLastUnrepliedMessages() {
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = spreadsheet.getSheetByName("ChatLog"); 

  if (!sheet) {
    Logger.log("「ChatLog」シートが見つかりません。");
    return []; 
  }

  const lastRow = sheet.getLastRow(); 
  if (lastRow === 0) {
    Logger.log("ChatLogシートにデータがありません。");
    return []; 
  }

  let lastRepliedRow = -1; // E列が空でない(返信済みの)最終行を初期化

  // 最終行から上に遡って、E列に初めて値がある行を探す
  for (let i = lastRow; i >= 1; i--) {
    const replyValue = sheet.getRange(i, 5).getValue(); // E列の値
    if (replyValue !== "" && replyValue !== null) {
      lastRepliedRow = i; 
      Logger.log(`E列に値がある最終行を発見: ${lastRepliedRow}`);
      break; 
    }
  }

  const unrepliedMessages = [];

  // 返信済みの行が見つからなかったら全行、見つかったらその次の行からを対象
  const startRowForUnreplied = (lastRepliedRow === -1) ? 2 : lastRepliedRow + 1; 

  if (startRowForUnreplied > lastRow) {
    Logger.log("新しい未返信メッセージはありません。");
    return []; 
  }

  Logger.log(`未返信メッセージをD列の${startRowForUnreplied}行目から${lastRow}行目まで取得します。`);
  const messageRange = sheet.getRange(startRowForUnreplied, 4, lastRow - startRowForUnreplied + 1, 1);
  const messages = messageRange.getValues(); 

  for (let i = 0; i < messages.length; i++) {
    const msg = messages[i][0]; 
    if (msg !== "" && msg !== null) {
      unrepliedMessages.push(msg);
      Logger.log(`取得した未返信メッセージ: ${msg}`);
    }
  }
  
  if (unrepliedMessages.length === 0) {
    Logger.log("E列が空のD列(未返信メッセージ)は見つかりませんでした。");
  }

  return unrepliedMessages;
}
/**
 * ChatLogシートから特定のUserIdの最終返信時刻(F列)を取得し、
 * それが現在時刻から指定された時間以上離れているかどうかを判定する。
 * @param {string} targetUserId 検索対象のユーザーID
 * @param {number} thresholdHours 判定基準となる時間(例: 6 を指定すると6時間)
 * @returns {boolean} 最終返信時刻が現在時刻から指定時間以上離れていればtrue、そうでなければfalse。
 * 該当するユーザーIDの返信が見つからない場合はtrue(長時間返信なしと見なす)
 */
function isLastReplyOlderThanThreshold(targetUserId, thresholdHours) {
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = spreadsheet.getSheetByName("ChatLog"); 

  if (!sheet) {
    Logger.log("「ChatLog」シートが見つかりません。");
    return true; 
  }

  const lastRow = sheet.getLastRow(); 
  if (lastRow === 0) {
    Logger.log("ChatLogシートにデータがありません。");
    return true; 
  }

  let lastReplyTime = null; 

  for (let i = lastRow; i >= 1; i--) {
    const userIdInRow = sheet.getRange(i, 2).getValue(); 
    const replyTimeInRow = sheet.getRange(i, 6).getValue(); 

    if (userIdInRow === targetUserId && replyTimeInRow !== "" && replyTimeInRow !== null) {
      if (replyTimeInRow instanceof Date) {
        lastReplyTime = replyTimeInRow;
        Logger.log(`ユーザーID「${targetUserId}」の最終返信時刻を発見: ${Utilities.formatDate(lastReplyTime, Session.getScriptTimeZone(), "yyyy/MM/dd HH:mm:ss")}`);
        break; 
      } else {
        Logger.log(`F列の値が予期せぬ型です。行: ${i}, 値: ${replyTimeInRow}, 型: ${typeof replyTimeInRow}`);
      }
    }
  }

  if (lastReplyTime === null) {
    Logger.log(`ユーザーID「${targetUserId}」の返信はChatLogシートで見つかりませんでした。指定時間以上前と見なします。`);
    return true; 
  }

  const currentTime = new Date(); 
  const timeDifferenceMillis = currentTime.getTime() - lastReplyTime.getTime();
  const thresholdMillis = thresholdHours * 60 * 60 * 1000;

  const isOlder = timeDifferenceMillis >= thresholdMillis;

  if (isOlder) {
    Logger.log(`ユーザーID「${targetUserId}」の最終返信は${thresholdHours}時間以上前です。`);
  } else {
    Logger.log(`ユーザーID「${targetUserId}」の最終返信は${thresholdHours}時間以内です。`);
  }

  return isOlder;
}

結論:AIとの新しい関係性

これらの機能は、AI LINE Botに「人間らしい間合い」「予測不可能な動き」を与えることを目的としています。常に完璧で即座な応答をするAIではなく、時には応答が遅れたり、予期せぬタイミングで話しかけてきたりすることで、ユーザーはAIをより身近で生きた存在として認識するかもしれません。

GASを活用することで、このようなきめ細やかな対話設計が可能です。今後も、AIとのコミュニケーションがより豊かになるような機能開発を進めていきます。

ご興味を持たれた方は、ぜひご自身のLINE Bot開発にもこれらのアイデアを取り入れてみてください。