50歳の整形外科医がはじめて作ったLINE Bot（手術説明LINE Bot)

# 目次
・医者です
・手術説明LINEBot（安心オペBot）
・使用した技術
・今後の展望

# 医者です
初めまして。はじめて投稿させていただきます。

私は医者25年の整形外科医です。プログラミングなどのテクノロジーに触れることがなかった私ですがひょんなことから医療者向けプロトタイピングスクールである[ものづくり医療センター](https://moicen-forest.studio.site/)に入院することとなりLINEBotを作ってしまいました。

医療の現場ではことあるごとに患者さんへ説明して納得いただいて診療を行います。特に手術となると事細かに説明する必要があり、時間もかなりかかることが多いです。説明する方も大変ですが、説明される患者さんも大変だと思います。

そんなことを解決するために**手術説明LINEBot**を作りました。

# 手術説明LINEBot（安心オペBot）

下のQRコードから友達追加すると実際に動かすことができます。

<img src="https://drive.google.com/uc?export=view&id=1QrUsOgY_QpvHvjPQDDjKQ6mQ_Zov8-kY" width="300">


LINEのリッチメニューをクリックすると始動し、カルーセルで知りたい情報（病気のこと、手術の方法、術後経過、手術の危険性など）を選ぶと病気の選択ができ、その病気の情報が得られるようなっています。


<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3989361/7dda8d69-0f55-4a69-9ffc-9ef947a1c8ac.png" width="300">


質問がある時は、リッチメニュをクリックすると入力できるようになり、それに対して返信されるようになっています。





# 使用した技術

<img src="https://drive.google.com/uc?export=view&id=1RvMEeD2ar1M7jAi1wpZ92-K_Kk6NkSQT" width="600">


情報の格納にはNotionを使いました。理由はドキュメント的な管理が簡単でリンクも使い易いことです。データベースを作成し、Notion APIをもちいて情報の編集へ対応できるようにしました。質問が入力されると、Googleスプレッドシートに自動保存されて、返答欄に返答を記入すると、自動送信されるようにしました。想定される質問に対してキーワードから自動返信される機能もつけました。

### Google Sheets

<img src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3989361/9fa62762-3eaa-4739-998e-9a49910ce85b.png" width="600">


### 質問応答のコード

```javascript
function doPost(e) {
  try {
    var json = JSON.parse(e.postData.contents);
    var events = json.events;
    var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("質問リスト");
    var faqSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("FAQ");
    var faqData = faqSheet.getDataRange().getValues(); // 先に取得しておく

    events.forEach(function(event) {
      if (event.type === "message" && event.message.type === "text") {
        var userId = event.source.userId;
        var message = event.message.text;
        var timestamp = new Date();

        if (message === "質問する") {
          sendLineMessage(userId, "質問をご入力ください。");
          return;
        }

        // FAQ から自動返信検索 (正規表現対応)
        var autoReply = null;
        for (var i = 1; i < faqData.length; i++) {
          var faqPattern = new RegExp(faqData[i][0], "i"); // 大文字小文字区別なし
          if (faqPattern.test(message)) {
            autoReply = faqData[i][1];
            break;
          }
        }

        var status = autoReply ? "自動返信済み" : "未回答";
        var replyText = autoReply || "ご質問ありがとうございます。確認して返答させていただきます。";
        
        sheet.appendRow([timestamp, userId, message, autoReply || "", status]);
        sendLineMessage(userId, replyText);
      }
    });

  } catch (error) {
    Logger.log("Error in doPost: " + error.toString());
  }

  return ContentService.createTextOutput("OK");
}

//管理はのLINE IDを取得する
function getAdminId() {
  var adminSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("管理者設定");
  if (!adminSheet) {
    Logger.log("エラー: '管理者設定' シートが存在しません");
    return null;
  }

  var adminId = adminSheet.getRange(2, 1).getValue().toString().trim();
  
  Logger.log("取得した管理者ID (生データ): '" + adminId + "'"); // スペースや改行をチェック

  if (!adminId || !adminId.startsWith("U") || adminId.length < 10) {
    Logger.log("エラー: 無効な管理者ID (adminId: '" + adminId + "')");
    return null;
  }

  return adminId;
}



// 管理者がスプレッドシートに入力した返信を送信
function sendReplyMessages() {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("質問リスト");
  var data = sheet.getDataRange().getValues();
  var updates = [];

  for (var i = 1; i < data.length; i++) {
    var status = data[i][4];
    var replyMessage = data[i][3];
    var userId = data[i][1];

    if (status === "未回答" && replyMessage !== "") {
      sendMultiLineMessage(userId, ["ご質問に回答いたします。", replyMessage]);
      updates.push([i + 1, "返信済み"]);
    }
  }

  // バッチ更新で処理を高速化
  updates.forEach(function(update) {
    sheet.getRange(update[0], 5).setValue(update[1]);
  });
}

// 未回答の質問リストを管理者へ通知
function sendUnansweredQuestions() {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("質問リスト");
  var data = sheet.getDataRange().getValues();
  var adminSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("管理者設定");
  var adminId = adminSheet.getRange(1, 1).getValue();
  var unanswered = [];

  for (var i = 1; i < data.length; i++) {
    if (data[i][4] === "未回答") {
      unanswered.push("▶ " + data[i][2] + "（ID:" + (i + 1) + "）");
    }
  }

  var message = unanswered.length > 0 ? "【未回答の質問リスト】\n" + unanswered.join("\n") : "未回答の質問はありません ✅";
  sendLineMessage(adminId, message);
}

//　LINE Notifyの設定
function sendLineNotifyMessage(message) {
  var token = PropertiesService.getScriptProperties().getProperty("LINE_NOTIFY_TOKEN"); // LINE Notify トークン
  if (!token) {
    Logger.log("エラー: LINE_NOTIFY_TOKEN が設定されていません。");
    return;
  }

  var url = "https://notify-api.line.me/api/notify";
  var headers = {
    "Content-Type": "application/x-www-form-urlencoded",
    "Authorization": "Bearer " + token
  };
  var payload = {
    "message": "\n" + message // 先頭に改行を入れると見やすくなる
  };

  var options = {
    "method": "post",
    "headers": headers,
    "payload": payload
  };

  try {
    var response = UrlFetchApp.fetch(url, options);
    Logger.log("LINE Notifyレスポンス: " + response.getResponseCode() + " - " + response.getContentText());
  } catch (error) {
    Logger.log("LINE Notify送信エラー: " + error.toString());
  }
}

// Gmailを管理
function sendGmailAlert(subject, body) {
  var adminEmail = PropertiesService.getScriptProperties().getProperty("ADMIN_EMAIL");

  if (!adminEmail) {
    Logger.log("エラー: 管理者のメールアドレスが設定されていません。");
    return;
  }

  try {
    MailApp.sendEmail({
      to: adminEmail,
      subject: subject,
      body: body
    });

    Logger.log("Gmail アラート送信完了: " + subject);
  } catch (error) {
    Logger.log("Gmail アラート送信エラー: " + error.toString());
  }
}


// 未回答の質問が一定数を超えたら通知
function notifyUnansweredQuestions() {
  var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("質問リスト");
  var adminSheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("管理者設定");
  var adminId = adminSheet.getRange(2, 1).getValue(); // 管理者のLINE IDを取得

  Logger.log("管理者ID: " + adminId);

  if (!adminId || typeof adminId !== "string" || !adminId.startsWith("U")) {
    Logger.log("エラー: 無効な管理者のLINE IDです。");
    return;
  }

  var threshold = PropertiesService.getScriptProperties().getProperty("UNANSWERED_THRESHOLD") || adminSheet.getRange(2, 2).getValue();
  var data = sheet.getDataRange().getValues();
  
  var count = data.filter(row => row[4] === "未回答").length - 1; 

  Logger.log("未回答の数: " + count + " / 閾値: " + threshold);

  if (count >= threshold) {
    var message = `⚠ 未回答の質問が ${count} 件あります！早めに対応してください。`;

    Logger.log("送信メッセージ: " + message);

    // LINE Notify に送信
    sendLineNotifyMessage(message);

    // Gmail でアラート送信
    sendGmailAlert("【未回答アラート】", message);
  }
}



// LINE API のリトライ対応（3回まで）
function sendLineMessage(userId, message) {
  if (!message || message.trim() === "") {
    Logger.log("エラー: 空のメッセージを送信しようとしました (userId: " + userId + ")");
    return; // 空のメッセージは送信しない
  }

  var attempts = 0;
  while (attempts < 3) {
    try {
      sendMultiLineMessage(userId, [message]);
      return;
    } catch (error) {
      Logger.log(`LINE送信エラー: ${error.toString()} (試行 ${attempts + 1}/3)`);
      Utilities.sleep(2000);
      attempts++;
    }
  }
}


// 複数メッセージ送信
function sendMultiLineMessage(userId, messages) {
  var token = PropertiesService.getScriptProperties().getProperty("LINE_ACCESS_TOKEN");
  var url = "https://api.line.me/v2/bot/message/push";
  var headers = {
    "Content-Type": "application/json",
    "Authorization": "Bearer " + token
  };

  // メッセージが空でないか確認
  var filteredMessages = messages.filter(msg => msg && msg.trim() !== "").map(text => ({ "type": "text", "text": text }));
  if (filteredMessages.length === 0) {
    Logger.log("エラー: 送信するメッセージがすべて空です (userId: " + userId + ")");
    return; // 空なら送信しない
  }

  var payload = JSON.stringify({ "to": userId, "messages": filteredMessages });

  var response = UrlFetchApp.fetch(url, { "method": "post", "headers": headers, "payload": payload });
  var responseCode = response.getResponseCode();
  var responseText = response.getContentText();

  Logger.log("LINE APIレスポンス: " + responseCode + " - " + responseText);

  if (responseCode !== 200) {
    throw new Error("LINE APIエラー: " + responseText);
  }
}

```




# 今後の展望
プログラミング初心者がなんとか作ったLINEBotですが、自分としてはやりたいことができたかなと思います。

情報を引き出すコードと質問応答コードを別々に作って機能するのを確認した後に合わせたのですが、なかなか動いてくれなくて大変でした。

まだ、実際に使ってはいないので、患者さんやその家族に使ってもらいフィードバックを得ながらUIデザインなど変更していけたらと思っています。また、手術以外の説明にも対応できたらと考えています。