💁‍♀️

GAS×LINE API×OpenAI APIで簡単にProactiveなチャットボットを作ろう

に公開

ProactiveなAIとは

こんにちは。Kouchと申します。
最近Proactive(積極的)AIという分野に興味を持っておりまして、そのプロトタイプを個人開発してみました。
例えば、ChatGPTのようなLLMのアプリケーションは、こちらからチャットを送ると、それに対して返事を返してくれます。基本的に受動的で、こちらから働きかけないと向こうから連絡は来ません。
このようなAIをReactive(受動的)AIと言います。

ただ、実際の人間とのやり取りでは、こちらから連絡をすることもあるし、逆に相手から連絡が来ることもあるはずです。
より人間に近い振る舞いをするAIを作りたいなと思っていて、「こちらから何かをしなくてもAIが主体的に行動を起こす」というProactive(積極的)AIを作ってみたいと思い、今回プロトタイプを作ってみました。
(とはいえ、今回のプロトタイプは「こちらからお願いをするとリマインドをしてくれる」という機能なので、半分Proactiveみたいなものなのですが…)

実際に作ったもの

LINEbotで、メッセージを送ると相談に乗ってくれるAI秘書を作りました。
ただ相談に乗ってくれるだけではなく、「リマインドして」などの指示を送ると、実際にリマインドを送ってくれます。

とにかく最小工数で作る!

今回の大方針としては、スケール性やエラーハンドリングなどは基本的にスキップし「とにかく最小工数でプロトタイプを作る」ということを重視してきました。

  • フロントエンドの実装をせずに、LINE上で動くチャットbotに
  • バックエンドのサーバーを持たずに、GASで簡単にデプロイできるように
  • DBを使わずに、スプレッドシートのそれぞれの行をレコードとみなして、DBの代わりにする
    という技術選定をしていました。

プロトタイプを作った流れ

STEP1 GASと公式LINEを繋ぐ

まずは、OpenAIのAPIなどは全く叩かずに、公式LINEにテキストを送ると、GASの処理が走るようにしましょう。
こちらの方の記事を大変参考にさせていただきました。
https://zenn.dev/miya_akari/articles/a8a4c296e7c1c6

流れとしては、

  • LINEのビジネス公式アカウントを作る
  • GAS上でコードを書き、webアプリとしてデプロイ
  • webアプリのURLを、LINEMessaging APIのwebhookURLに指定する

    こんな形になります。

こうすることによって、公式LINEにメッセージが送られると、GASのdoPostという関数が呼ばれるようになります。
このdoPostの引数にLINEのメッセージの内容や、送信元のuser_idが渡されてくるので、それを使って返信を作ることになります。
このタイミングで、受け取ったメッセージの内容をそのまま返す「おうむ返しbot」などが作れるようになります。

コードとしてはこんな形です。

const properties = PropertiesService.getScriptProperties();
const access_token = properties.getProperty('LINE_CHANNEL_ACCESS_TOKEN');
const line_reply_api = 'https://api.line.me/v2/bot/message/reply';

function doPost(e) {
  const event = JSON.parse(e.postData.contents).events[0];
  const replyToken = event.replyToken;
  const userText = event.message.text;

  const payload =  {
    replyToken: replyToken,
    messages: [
      {
          type: "text",
          text: userText,
      }
    ]
  };

  const params = {
    method: 'post',
    contentType: 'application/json; charset=UTF-8',
    headers: {
      Authorization: 'Bearer ' + access_token
    },
    payload: JSON.stringify(payload)
  };

  UrlFetchApp.fetch(line_reply_api, params);
};

STEP2 OpenAIのAPIを叩いてみよう!

さて、ここからが面白いところです。
このdoPostの中でLINEの返信メッセージを作るわけですが、この中でOpenAIのAPIを叩いてみましょう。
単純にテストするために、「単語を送ったらそれについて解説してくれるbot」を作ってみました。


先ほどのdoPostの中で、OpenAIに返信文を作ってもらう処理を呼び出します。

function generateTextWithGPT(userText) {
    const payload = {
      model: 'gpt-5-chat-latest',
      messages: [
        { role: 'system', content: 'あなたはフレンドリーな解説員です。ユーザーの入力に対して、その単語の説明やちょっとした豆知識を200文字程度で返してください。 返答は口語的でその単語に関する内容だけ返してください。 (わかりました、などの返事は不要です)' },
        { role: 'user',   content: userText }
      ],
      // 必要に応じて:
      // temperature: 0.7,
      max_tokens: 200,
    };

    const params = {
      method: 'post',
      contentType: 'application/json; charset=UTF-8',
      headers: {
        Authorization: 'Bearer ' + openai_key
      },
      payload: JSON.stringify(payload)
    };

    const res = UrlFetchApp.fetch(openai_api, params);

    const json = JSON.parse(res.getContentText());
    // Chat Completionsの返却形式:choices[0].message.content
    const text = (json && json.choices && json.choices[0] && json.choices[0].message && json.choices[0].message.content) || '(すみません、うまく生成できませんでした)';
    return text.trim();
};

STEP3 過去の履歴を保存して、OpenAI APIに送ろう

ですがまだまだ秘書には足りません!このまま秘書っぽくすると、毎回記憶がリセットされてしまいます。
普段使っているChatGPTのアプリでは、何もしなくてもチャットの履歴を理解して文脈に沿って返信をしてくれていますが、API経由の場合は、過去の履歴もまとめて送らないといけません。


こんな感じで、過去の会話を毎回リセットされてしまいます。

ということで、チャットの送受信の履歴をスプレッドシートに保管するようにしましょう。

メッセージ送信、受信のタイミングでスプレッドシートにデータを保管

function logChatToSheet(userId, direction, text) {
  const sheet = getSheet('chat_log');
  if (sheet.getLastRow() === 0) {
    sheet.appendRow(['timestamp', 'user_id', 'direction', 'text']);
  }

  sheet.appendRow([new Date(), userId, direction, text]);
}

OpenAIのAPIを叩くときに、チャット履歴をスプレッドシートから取得してまとめる

function getChatHistory(userId) {
  const sheet = getSheet('chat_log');
  const data = sheet.getDataRange().getValues();

  const userChat = data.filter(row => row[1] === userId);

  // userChatの各行は [timestamp, user_id, direction, text] の形式
  // これをChatGPTの会話履歴形式に変換
  // directionが'user'か'assistant'に対応
  // 例:
  // { role: "assistant", content: "アシスタントの性格や目的の指定" },
  // { role: "user", content: "ユーザーの発言" },
  // { role: "assistant", content: "モデルの返答" },
  // { role: "user", content: "次の発言" },

  const chatHistory = userChat.map(row => {
    return { role: row[2], content: row[3] };
  });

  return chatHistory;
}

これによって、スプレッドシートにチャット記録が残るようになりました。

そのチャット履歴の含めてOpenAIのAPIに送ることによって、過去の履歴を踏まえた、文脈を理解した返信をしてくれるようになりました!

STEP4 リマインドは必要かどうかを判定させよう

さて、このままではChatGPTの下位互換になってしまいます。
ここからリマインド機能を入れて、ちょっとだけProactiveっぽさを出してみましょう

まず、LINEのメッセージを受け取ったタイミングで発火されるdoPostの中で、「受け取ったメッセージがリマインドが必要かどうか」を判定させましょう。
OpenAIのAPIを2回叩くことになり、それぞれ別のプロンプトを利用して、
1.リマインドが必要であれば抽出するAI
2.その上で返信文を作るAI
の二つの役割を担ってもらいます。

function extractTriggerWithGPT(userText, chatHistory) {
  const nowIsoJst = Utilities.formatDate(new Date(), 'Asia/Tokyo', "yyyy-MM-dd'T'HH:mm:ssXXX");
  const userInput = {
    text: userText,
    now: nowIsoJst,
  }

  const payload = {
    model: 'gpt-5-chat-latest',
    messages: [
      { role: 'system', content: extractTriggerPrompt },
      ...chatHistory,
      { role: 'user',   content: JSON.stringify(userInput) }
    ],
    max_tokens: 200,
    temperature: 0.1,
  };

  const params = {
    method: 'post',
    contentType: 'application/json; charset=UTF-8',
    headers: {
      Authorization: 'Bearer ' + openaiKey
    },
    payload: JSON.stringify(payload)
  };

  const res = UrlFetchApp.fetch(openaiApi, params);

  const json = JSON.parse(res.getContentText());
  const text = (json && json.choices && json.choices[0] && json.choices[0].message && json.choices[0].message.content) || '';
  return pickJsonOrNull(text);
};

ここでのちょっとしたコツは、ユーザーのメッセージだけでなく、現在時刻も一緒に渡すことです。
ユーザーのメッセージが「5分後にリマインドして!」みたいな指示の時、AIは自分で時間を取りに行かずに、時間をテキトーにでっち上げます。
なので、ユーザーのメッセージとは別に、現在時刻も送る必要があります。

プロンプトはこんな感じ

あなたはリマインド抽出アシスタントです。入力のユーザー発言から、いつ、何をリマインドするかを抽出して構造化してください。
出力は JSON のみで返してください。説明文やコードブロックやタグは一切出さないこと。

要件
(1) リマインド意図がある場合のみ JSON を返す。ない場合は null を返す。
(2) 相対時刻は now を基準に Asia/Tokyo の ISO 8601 形式で変換する。例 2025-10-15T09:00:00+09:00
(3) 実際にその時間に送る短い自然文を reminder_text に入れる。

入力フォーマット
{ "text": "...ユーザー発言...", "now": "YYYY-MM-DDTHH:MM:SS+09:00" }

出力フォーマット
{
    "when_iso": "YYYY-MM-DDTHH:MM:SS+09:00",
    "action": "先生に連絡する",
    "reminder_text": "わかりました。明日リマインドしますね。",
}

注意
(1) 不明確な場合は null を返す。でっち上げ禁止。
(2) reminder_text はそのまま送れる一文にする。顔文字や過剰な装飾は避ける。

ここで、リマインドするTriggerを抽出するだけでなく、リマインド時のメッセージまで作ってあげます。

また、これもちょっとしたコツですが、「ここでリマインドするTriggerを保存したよ」という事実を、返信文を作るAIにも渡してあげることが大事です。
これを行なってないと、二つのAIの判断が異なるときに、口では「OK!5分後にリマインドするね」と言いながらも、実際はTriggerが作られていない、なんて状況が発生してしまいます。

こんな形で、payloadにTriggerを渡してあげます。

const payload = {
    model: 'gpt-5-chat-latest',
    messages: [
      { role: 'system', content: replyChatPrompt },
      { role: 'system', content: "extractTrigger: " + JSON.stringify(extractedTrigger) },
      ...chatHistory,
      { role: 'user',   content: userText }
    ],
    // 必要に応じて:
    // temperature: 0.7,
    max_tokens: 200,
  };

STEP5 実際にTriggerを発火させて、メッセージを送ろう

STEP4で、Triggerを抽出したら、それをチャット履歴とは別のシートに保存してあげます。

最後はこれを発火させるだけです。
GASを使うと、これも実はめちゃめちゃ簡単にできてトリガーという機能があり、GUI上で指定した関数を毎分や毎時間実行してくれます。
なので自分でサーバーを立ててcron回す、なんてことも必要なし!

ここで指定した関数を毎分呼び出して、「現在時刻から1分以内のTriggerを発火させる」という処理を書きましょう。

const line_push_api = 'https://api.line.me/v2/bot/message/push';

// GASの設定により、この関数は毎分実行されます。
function fireTrigger() {
  const sheet = getSheet('trigger');
  const data = sheet.getDataRange().getValues();

  const now = new Date();
  const oneMinuteLater = new Date(now.getTime() + 1 * 60 * 1000);

  // rowの形式: [timestamp, user_id, when_iso, action, reminder_text]
  const triggersToFire = data.filter(row => {
    if (row[0] === 'timestamp') return false;
    
    const [timestamp, userId, whenIso, action, reminderText] = row;
    const triggerTime = new Date(whenIso);
    return triggerTime >= now && triggerTime < oneMinuteLater;
  });

  triggersToFire.forEach(row => {
    const [timestamp, userId, whenIso, action, reminderText] = row;
    postMessage(userId, reminderText);
  });
}

function postMessage(userId, reminderText) {
  const payload = {
    to: userId,
    messages: [
      {
        type: "text",
        text: reminderText,
      }
    ]
  };

  const params = {
    method: 'post',
    contentType: 'application/json; charset=UTF-8',
    headers: {
      Authorization: 'Bearer ' + accessToken
    },
    payload: JSON.stringify(payload)
  };

  UrlFetchApp.fetch(line_push_api, params);
}

今回はプロトタイプなのでガバガバですが、この処理は毎回全てのTriggerを読むことになるので、ユーザーが増えると崩壊すると思います。
ただ、今回はあくまでプロトタイプでほぼ僕だけが使う想定なので、一旦この形にしました。

これで、一定時間後にリマインドをしてくれるようになりました!

とりあえずプロトタイプ、完成です!

全体的な流れはこんな形になります。

今後やりたいこと

とはいえまだまだやりたいことはあります。
まずは、現状の仕組みがスケールしない前提になっているので、スプレッドシートをDBにして検索効率を上げたり、などはしていきたいですね。

また、現状は過去のチャット履歴を全てAPIに送っているのですが、これもデータが溜まってくると上限を超えてしまいます。
本格的にやるのであれば、チャット履歴の中で本当に大事な情報だけ長期記憶として保存して、長期記憶と直近の10メッセージを送る、みたいな仕様がいいかもしれません!

最後に

GASもLINE MessageAPIもOpenAI APIも、全部使ったことがほぼなかったのですがAIの助けを借りてとりあえず一通りは実装できました!
AIによって新しい技術を学ぶハードル下がってていいなぁ

これからも新しい技術に触れていきたいと思います!

Discussion