見出し画像

LINEのBOTで実験の声かけしてみる(4)

これまで

実験中に被験者に声をかけるためのLINE BOTを作っている。LINEのAPIとGoogleスプレッドシートのGASを使ってメッセージを送れたが「6分の壁」がに阻まれたのが1回目。

タイマートリガーを使って「6分の壁」を解決して一件落着。しばらくテスト運用してみたのが2回目。

テストしてたら不可解な動きが!作ったタイマートリガーをちゃんと後始末しないといけないことに気づき四苦八苦。タイマートリガーの仕様で20個まで、つまり同時に20人までしか実験できないことが発覚したのが3回目。

同時に20人という制限はあるものの1ヶ月ほどテスト。「あそこを…」「ここを…」とやりたい事が泉のように湧き出てくる。ソフトウェア開発の醍醐味。

改善1:実験時間をフレキシブルにしたい

「実験は1回60分」という前提だったが「1回30分または60分」となり「自由に変更できるようにしたい」と発展。管理者モードを作って、管理者(=特定のユーザーID)が「呪文 20分に設定せよ」とメッセージを送ると60分だったのが20分で実験できるようにするとか、「呪文 4回声がけに設定せよ」とメッセージを送ると6回だったのが4回の声がけに変更できるとか、夢が膨らむがオーバースペックなのでそこまではやらず、被験者が自由に実験時間を設定できるようにする。

改善2:同時使用時に時々声がけが抜ける?

被験者が一人の時はOKなのだが、複数になった時に声がけのメッセージが抜ける現象が起きた。バグの匂いがする。GASのタイマートリガーは分単位で実行されるという仕様。被験者Aの3番目の声がけが5時10分05秒で、被験者Bの2番目の声がけが5時10分49秒だったとして秒の単位は無視されるので同じ5時10分のタイマーが実行されどちらか(おそらく被験者Bの方)が実行されないようだ。それだけではなく、被験者Cの1番目の声がけが5時09分57秒だったとすると実行までのタイムラグで同じ5時10分になってしまうことがあり不可解な現象になっていたようだ。
タイマートリガーの使い方を抜本的に見直し、1個のタイマーで全員分を処理することにした。最初の被験者のタイミングでタイマートリガーを作る。今度のタイマートリガーは1分ごとに繰り返すことにした。つまり1分ごとにプログラムが呼び出される。待ち行列 queueに列挙された声がけのスケジュールを上から順番に見ていき、時間が過ぎているスケジュールを実行し待ち行列queueから削除することにした。最後の被験者の最後の声がけ、正確には待ち行列queueがゼロになったらタイマートリガーを消すことにした。

改善3:細かく記録したい

Googleスプレッドシートの「log」シートに何時何分何秒にトークがきて返事がきたかを記録している。今までは時分秒だったが実験に実施日も記録したいので年月日時分秒にしたい。Utilities.formatDate() 関数の3番目の引数を 'HH:mm:ss' から 'yyyy/MM/dd HH:mm:ss' に変更する。詳細を知らなくても何となくでもわかると思う。詳細は次章。ここでは簡単に。時間hourが大文字のHHだったのは24時間制にしたかったから。12時間制ならhh。分minuteはmmなのでそれと区別するため月monthは大文字のMMになる。

function logZ( uuu, xxx) {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = ss.getSheetByName("log");
  var timestamp = Utilities.formatDate( new Date(),'Asia/Tokyo', 'yyyy/MM/dd HH:mm:ss');
  sheet.appendRow([timestamp, uuu, xxx]);
}
ログのタイムスタンプを時分秒から年月日時分秒へ

参考:Utilities formatDate()

GASのUtilitiesクラスの説明にはformatDate()メソッドの3番目の引数について詳しく書かれておらず、オラクルのJavaの説明をリファーしている。

改善4:ランダムな時間の精度を高めたい

次の声がけまでの時間をランダムにしたい。60分の実験で声がけを6回行う時に7分から11分のランダムな待ち時間で、5回目から6回目の待ち時間は60分からいままでのかかった時間を引いて辻褄を合わせていた(関数mkIntervalZ)。
残り時間の50〜150%の範囲のランダムに改善したのが関数mkIntervalZ2。つまり、残り時間が24分なら12〜36分の範囲でランダムな待ち時間にするという改善。
最終的には可変になった実験時間を声がけ回数で割った時間、例えば、実験時間が40分で声がけが4回なら、40÷4=10、その2/3の6分40秒から10分までのランダムな待ち時間にすることにした(関数 mkIntervalZ3)。そうなると最後の声がけまでの時間が長めになるがよしとした。

function mkIntervalZ() { // 次の声がけまでの時間をランダムに決める
  var rrr;
  rrr = Math.round(7 + Math.random() * 4); // 7分 + ランダムで0,1,2,3,4分
  return rrr;
}

function mkIntervalZ2( progressTime, stepNum) { // 次の声がけまでの時間をランダムに決める
  var rrr;
  var aveWait = (60 - progressTime)/(maxStep - stepNum +1);
  rrr = Math.round( aveWait/2 + Math.random() * aveWait); 
  //Logger.log( "aveWait="+aveWait+", random="+rrr);
  return rrr;
}

function mkIntervalZ3() {
  var rrr;
  rrr = ( (gExPeriod/gMaxStep)*(2/3)+Math.random() * ((gExPeriod/gMaxStep)*(1/3)));
  return rrr;
}

大手術を施したプログラム

タイマーまわりを全面的に書き換えた。プログラム的に改善の余地がある。全角を半角に変換している関数 Zen2HanZ は0から9まで10個のreplaceにしているがもっとスマートな書き方に変えたい。関数execMinuteTriggerZは大きくなりすぎている。大きなコード、複雑なコードはバグの温床。タイマートリガーは1個しか使わない予定だが、実験が終われば全部のタイマーを削除しているのもいただけない。本番の実験が迫っているのでこれでテストを続けてみる。

function Zen2HanZ( sss) {
  sss=sss.replace( /0/g, "0");
  sss=sss.replace( /1/g, "1");
  sss=sss.replace( /2/g, "2");
  sss=sss.replace( /3/g, "3");
  sss=sss.replace( /4/g, "4");
  sss=sss.replace( /5/g, "5");
  sss=sss.replace( /6/g, "6");
  sss=sss.replace( /7/g, "7");
  sss=sss.replace( /8/g, "8");
  sss=sss.replace( /9/g, "9");
  return sss;
}

// 時間内に何回メッセージを送るか
const MAX_STEP = 4;

// ステップ数(stepNum)に応じたメッセをユーザ(userId)に送る
function postMessageStepZ(userId, stepNum) {
  if ( stepNum == -1 ) {
    postMessageZ( userId, "何分にしますか? 30分にするなら 30 と入力してください");
  }
  else if ( stepNum == 0 ) {
    postMessageZ( userId, "0:では始めてください。");
  }
  else if ( stepNum == 1 ) {
    postMessageZ( userId, "1:順調ですか?");
  }
  else if ( stepNum == 2 ) {
    postMessageZ( userId, "2:その調子です。");
  }
  else if ( stepNum == 3 ) {
    postMessageZ( userId, "3:良いペースです。");
  }
  else if ( stepNum == 4 ) {
    postMessageZ( userId, "4:お疲れ様でした。");
  }
  // MAX_STEP を変更したらステップ数に応じてメッセージを設定すること
  else if ( stepNum == 5 ) {
    postMessageZ( userId, "5:5回目のメッセージ");
  }
  else if ( stepNum == 6 ) {
    postMessageZ( userId, "6:6回目のメッセージ");
  }
  else {
    postMessageZ( userId, "x:このメッセージは表示されることはない");
  }
}
// メッセージとメッセージの間隔
function mkIntervalZ(exPeriod) {
  var rrr;
  //      30     2          30
  // min ---- x --- 〜 max ----
  //      4      3          4
  rrr = (exPeriod/MAX_STEP)*(2/3)+Math.random() * ((exPeriod/MAX_STEP)*(1/3));
  return rrr;
}

// 1分タイマーを作る
function makeMinuteTriggerZ() {
  var trigger = ScriptApp.newTrigger('execMinuteTriggerZ').
    timeBased().
    everyMinutes(1).
    create();
  Logger.log( "create minute trigger #"+trigger.getUniqueId());
}

// トリガーを全て削除、1分タイマー以外も削除するので注意
function deleteTrigger() {
  var allTriggers = ScriptApp.getProjectTriggers();
  for (var iii = 0; iii < allTriggers.length; iii++) {
    Logger.log( "delete minute trigger #"+iii+" "+allTriggers[iii].getUniqueId());
    ScriptApp.deleteTrigger(allTriggers[iii]);
  }
}

// 次のスケジュールを待ち行列に設定
function setNextScheduleZ( userId, stepNum, progressTime, exPeriod) {
  // 次までの待ち時間を作って iii に格納
  var iii;
  if ( stepNum < MAX_STEP) {  // ステップ(stepNum)が1,2,3の時
    iii = mkIntervalZ(exPeriod);
  }
  else {               // ステップ(stepNum)が4回(MAX_STEP)の時は残り時間
    iii = exPeriod - progressTime - 2;
  }
  // 次の実行スケジュールを年月日時分秒にしてnextScheduleに格納
  var ddd = new Date();
  ddd.setSeconds(ddd.getSeconds()+iii*60);
  var nextSchedule = Utilities.formatDate( ddd, 'Asia/Tokyo', 'yyyy/MM/dd HH:mm:ss');
  // 待ち行列に加えて、昇順に並べ替えておく
  addQueueZ( userId, nextSchedule, stepNum, progressTime + iii, exPeriod);
  sortQueueZ();
}

// 1分ごとに呼び出されるタイマーで実行
function execMinuteTriggerZ() {
  // 待ち行列がゼロならタイマーを止める
  var maxExperience = howManyQueueZ();
  if ( maxExperience == 0 ) {
    deleteTrigger();
    return;
  }
  // スプレッドシートを操作するのでロックしてから実行
  var lock = LockService.getScriptLock();
  if (lock.tryLock(10*1000)) {  
    var now = new Date();
    var ss = SpreadsheetApp.getActiveSpreadsheet();
    var sheet = ss.getSheetByName("queue");
    var range = sheet.getRange(1,1,1,5);
    // 全てのスケジュール(被験者の人数分)
    Logger.log("maxExperience="+maxExperience);
    for ( var iii=0; iii< maxExperience; iii++ ) {
      var schedule = range.getValues();
      var userId = schedule[0][0];
      var fireTime = new Date(schedule[0][1]);
      var stepNum = schedule[0][2];
      var progressTime = schedule[0][3];
      var exPeriod = schedule[0][4];    
      Logger.log(schedule[0]);

      // 過去の=過ぎたスケジューを全て処理
      if ( fireTime < now) {
        delQueueTopZ(); // 先頭を削除
        postMessageStepZ( userId, stepNum); // メッセージ送り
        if ( stepNum < MAX_STEP) {
          setNextScheduleZ( userId, stepNum+1, progressTime, exPeriod); // 次のスケジュールをセット
        }        
      }
      else {
        Logger.log( "nop");
      }
    }
    lock.releaseLock();
  }
  else {
    Logger.log("Locked!");
  }
}

function howManyQueueZ() {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = ss.getSheetByName("queue");
  var rrr = sheet.getLastRow();
  Logger.log("last row="+rrr);
  return rrr;
}

function sortQueueZ() {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = ss.getSheetByName("queue");
  var range = sheet.getRange(1,1,20,6);
  range.sort(2); // range.sort({column: 2, ascending: true})
}

function delQueueTopZ() {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = ss.getSheetByName("queue");
  var range = sheet.getRange(1,1,1,6);
  range.deleteCells(SpreadsheetApp.Dimension.ROWS);
}

function delQueueAllZ() {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = ss.getSheetByName("queue");
  var range = sheet.getRange(1,1,20,6);
  range.deleteCells(SpreadsheetApp.Dimension.ROWS);
}

function addQueueZ( userId, nextTime, stepNum, progressTime, exPeriod) {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = ss.getSheetByName("queue");
  sheet.appendRow([userId, nextTime, stepNum, progressTime, exPeriod]);
}

function getTokenZ() {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = ss.getSheetByName("secret");
  var token = sheet.getRange(1, 2).getValue();
  //Logger.log( token);
  return token;
}

function logZ( uuu, xxx) {
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = ss.getSheetByName("log");
  var timestamp = Utilities.formatDate( new Date(),'Asia/Tokyo', 'yyyy/MM/dd HH:mm:ss');
  sheet.appendRow([timestamp, uuu, xxx]);
}

function postMessageZ( ttt, mmm) { // 相手(ttt)にメッセ(mmm)を送る
  logZ( ttt, mmm)
  const url = 'https://api.line.me/v2/bot/message/push';
  const payload = {
    to: ttt, 
    messages: [{
      type: 'text',
      text: mmm 
    }]
  };
  const params = {
    method: 'post',
    contentType: 'application/json',
    headers: {
      Authorization: 'Bearer ' + getTokenZ()
    },
    payload: JSON.stringify(payload)
  };

  UrlFetchApp.fetch(url, params);
}

function doPost(e) {
  let token = getTokenZ(); 
  let json = JSON.parse(e.postData.contents);
  let userId = json.events[0].source.userId;
  let userMessage = json.events[0].message.text;
  var rrr;
  var um = userMessage;
  logZ( userId, "受信:"+userMessage);
  userMessage=Zen2HanZ( userMessage);
  if ( userMessage.match(/開始/)) {
    postMessageStepZ( userId, -1);
  }
  else if ( rrr=userMessage.match(/\d{1,}/)) {
    var exPeriod;
    Logger.log(rrr);
    exPeriod=rrr[0];
    if ( exPeriod < 30){
      postMessageZ( userId, "30分以上に設定してください");
    }
    else if ( exPeriod > 100) {
      postMessageZ( userId, "100分以下に設定してください");
    }
    else {
      postMessageZ( userId, exPeriod+"分に設定しました。");
      postMessageStepZ( userId, 0);
      // 待ち行列がゼロ、ということは、1分タイマーがまだないので作る
      if ( howManyQueueZ() == 0 ) {
        makeMinuteTriggerZ();
      }
      // 1回目のスケジュールをセット
      setNextScheduleZ( userId, 1, 0, exPeriod);
    }
  }
  else {
    postMessageZ( userId, "何?");   
  }
} 

この記事が気に入ったらサポートをしてみませんか?