🕹️

【脳死で作る】chatGPT APIとGASで記事を要約しnotionに保存できるSlack botを作ってみる

2023/04/02に公開1

はじめに

この記事はリンクを送ると記事の要約を返信し、notionに保存もできるSlack botの開発工程を紹介しています。
非エンジニアの自分でもchatGPTの力を借りることで、あまり詰まる事なく簡単に開発する事ができました。

技術知識があまりない方も簡単に作成できる時代になっているので、ぜひチャレンジしてみてください!
※この記事もChatGPTで下書きを作成しています。

利用するサービス

以下のサービスを使っています。

  • Google App Script (GAS)
  • chatGPT API
  • notion API
  • Slack API

botの使い方と機能

  1. botをインストールしたチャンネルに記事のリンクを投稿
    image
  2. botがchatGPT APIから要約を取得、スレッドに返信
    image
  3. 返信についているボタンを押すと記事のタイトル、URL、要約をnotionのデータベースに保存
    image
    image
  4. リンクを含まないメッセージの場合、botとのやり取りが可能
    image

botの利用方法と開発した背景

開発のモチベーションの9割はchatGPT APIで遊びたかったからでしたが、主に英語記事のインプットに使っています。
ざっくり概要を知ってから読むだけで相当効率がよくなったので結果オーライです。🙆

作成手順

1. 各種APIキーの取得

各種、後ほど利用するので忘れないように控えておきましょう。

  • chatGPT API
    • ここの「Create new secret key」から取得。
  • notion API
    • ここにアクセスし「新しいインテグレーション」を押す。
    • 名前は適当なものでOK、画像のように設定されていれば送信押して次の画面からトークンを取得。
      image

2. notionの設定

  • 記事を保存するデータベースを用意します。

    • 今回は以下のようにタイトル、urlを用意するものとします。(同じ構成にしてください)
      image
  • データベースのIDを取得

  • コネクトの追加

    • データベースをフルページで表示したページで右上のメニューを開き、「コネクトを追加」から「各種APIキーの取得」で作成したインテグレーションを追加
      image

3. Slack botの作成①

  • ここの「Create New App」→「From scratch」→名前を設定&Workspaceを指定しAppを作成します。
  • 左メニューのOAuth & Permissionを開き、Scopes > Bot Token Scopesで「Add an OAuth Scope」をクリック。下記の2つのScopeを追加します。(画像のようになっていればOKです。)
    • channels:history
    • chat:write

image

  • 同画面トップの「OAuth Tokens for Your Workspace > Install to Workspace」からWorkspaceにbotをインストールする。

  • インストールを許可すると、以下のようなBot User OAuth Tokenが発行されるので控えておく。
    image

ここからSlackのアプリに移ります。

  • botを利用するチャンネルにbotをインストール(設定したアプリ名で検索すればでてきます。)

  • botのIDを取得

    • botのDMを開き画像部分をクリックしていくと現れる「メンバーID」がbot IDになります。(控えておく)
      image
  • SlackチャンネルのIDを取得

Slack botの設定は一旦ここまで。
後ほどまた続きを設定します。

4. GASの設定とコード

  • ここからGASのプロジェクトを作成。

  • 左メニューの歯車 > ページ下部にあるスクリプトプロパティを以下のように設定。
    ※プロパティ名を異なるものにすると正しく動作しないので注意。(コードで利用するものと同じ名前にする必要があります。)

    プロパティ 説明
    NOTION_TOKEN 1.で取得したnotion APIのトークン
    OPEN_AI_KEY 1.で取得したchatGPT APIのAPIキー
    SLACK_BOT_TOKEN 1.で取得したBot User OAuth Token
    NOTION_DB_ID 2.で取得したnotionのデータベースのID
    BOT_ID 3.で取得したSlack botのID
    SLACK_CHANNEL_ID 3.で取得したSlackのチェンネルID

    image

  • Slackとの連携に利用するライブラリを追加(SlackAPp)

    • エディタ画面を開き、GASの左側にあるライブラリの”+” をクリック → 以下のスプリクトIDを入力し、検索し追加する

    1on93YOYfSmV92R5q59NpKmsyWIQD8qnoLYk-gkQBI92C58SPyA2x1-bq

    image

  • 以下のコードをコピペし保存します。

コード全体
main.js
const prop = PropertiesService.getScriptProperties().getProperties();
//chatGPT APIにリクエストする際のtoken数の上限を指定
const limitToken = 3500;

//botをインストールされたチャンネル内でメッセージが投稿された時に実行される
function doPost(e) {
  let app = SlackApp.create(prop.SLACK_BOT_TOKEN);
  let parameter = e.parameter;
  let userId = parameter.user_id;

  //botのメッセージには何もしない
  if (userId === prop.BOT_ID || !userId) return;

  let messageTs = parameter.timestamp;
  let threadTs = parameter.thread_ts;
  let message = parameter.text;

  //投稿されたメッセージが指定したtoken数(limitToken)を超えた場合はアラートメッセを返し、何もしない
  if (message.length > limitToken) {
    app.postMessage(
      prop.SLACK_CHANNEL_ID,
      `【文字数オーバー】\n\n${limitToken}字以内にしてください。`,
      {
        thread_ts: messageTs,
      }
    );
    return;
  }

  let url = extractUrls(message)[0];
  let title = getWebpageTitle(url);

  //chatGPT APIようにSlackのメッセージを整形する+スレッド内のやりとりを取得
  let threadTextJson = getSlackThreadMessageJson(
    userId,
    threadTs,
    url,
    title,
    message
  );
  //chatGPTからの返答テキスト
  let gptMessage = getChatGptResponseText(threadTextJson);
  //notionへの追加ボタンの設定
  let attachments = createNotionAttachment(url, title, gptMessage);

  try {
    //スレッドにメッセージを送信
    app.postMessage(prop.SLACK_CHANNEL_ID, gptMessage, {
      thread_ts: messageTs,
      attachments: attachments,
    });
  } catch (e) {
    //エラー通知
    recordAndNotifyError(e);
  }
}

//chatGPT APIようにSlackのメッセージを整形する+スレッド内のやりとりを取得
function getSlackThreadMessageJson(userId, threadTs, url, title, message) {
  const slackReplyEndpoint = "https://slack.com/api/conversations.replies";
  // 要約させるプロンプト。ここは自分なりに変えてみると納得する返答がもらえると思います。
  // 自分の場合は、以下のような形式をとっています。
  // ・記事のタイトル
  // ・全文の要約
  // ・目次
  // ・目次ごとの要約
  let summaryTemplate =
    "これからのやり取りのルールを以下のように決めます。" +
    "\nルールに従って回答して下さい。" +
    "\n#ルール" +
    "\n1:リンクのみを送られた場合" +
    "\n2:タイトルとリンクを送られた場合" +
    "\nそのリンク先の文書を次の要求に従って日本語で要約し出力する。" +
    "\n#要求" +
    "\nあなたはプロの編集者です。" +
    "\n以下の制約条件と入力文をもとに最高の要約を出力してください。" +
    "\nこのタスクで最高の結果を出すために、追加の情報が必要な場合は、質問をしてください。" +
    "\n> 制約条件:" +
    "\n・重要なキーワードを取り残さない。" +
    "\n・文全体の要約を作成し文頭に表示する。" +
    "\n・目次を作成して文頭の次に表示する。" +
    "\n・目次ごとに要約する" +
    "\n#output:" +
    "\n{文全体の要約}" +
    "\n{目次}" +
    "\n{目次ごとの要約}" +
    "\n output:";

  let json = !!url
    ? [
        { role: "assistant", content: summaryTemplate },
        { role: "user", content: title + "\n" + message },
      ]
    : [{ role: "user", content: message }];

  let prams = {
    method: "get",
    headers: {
      Authorization: "Bearer " + prop.SLACK_BOT_TOKEN,
      contentType: "application/json",
    },
    muteHttpExceptions: true,
    payload: {
      channel: prop.SLACK_CHANNEL_ID,
      ts: threadTs,
    },
  };

  try {
    let response = UrlFetchApp.fetch(slackReplyEndpoint, prams);
    let threadMessages = JSON.parse(response.getContentText()).messages;
    //指定したtoken数(limitToken)を超えない範囲でスレッドのメッセージを最新順に取得する
    if (!!threadMessages) {
      threadMessages.sort((a, b) => (a.ts >= b.ts ? -1 : 1));
      let totalTokenCount = message.length;
      for (let e of threadMessages) {
        let text = e.text;
        totalTokenCount += text.length;
        if (totalTokenCount > limitToken) break;
        let role = userId !== prop.BOT_ID ? "user" : "assistant";
        json.unshift({ role: role, content: text });
      }
    }
  } catch (e) {
    recordAndNotifyError(e);
  }
  return json;
}

//chatGPT APIから応答を受け取る
function getChatGptResponseText(slackThreadJson) {
  const chatGptApiEndpoint = "https://api.openai.com/v1/chat/completions";
  let chatGptApiKey = prop.OPEN_AI_KEY;
  let responseText = "";
  try {
    let response = UrlFetchApp.fetch(chatGptApiEndpoint, {
      method: "POST",
      headers: {
        Authorization: "Bearer " + chatGptApiKey,
        "Content-Type": "application/json",
      },
      payload: JSON.stringify({
        model: "gpt-3.5-turbo",
        messages: slackThreadJson,
        temperature: 0,
      }),
    });
    let responseData = JSON.parse(response.getContentText());
    responseText = responseData.choices[0].message.content;
  } catch (e) {
    recordAndNotifyError(e);
  }
  return responseText;
}

//notionへの追加ボタンの設定
function createNotionAttachment(url, title, gptMessage) {
  let attachments = !!url
    ? [
        {
          title: title,
          title_link: url,
          fallback: "fallback",
          callback_id: "add_to_notion",
          color: "#3AA3E3",
          attachment_type: "default",
          actions: [
            {
              name: "yes",
              text: "notionに追加",
              type: "button",
              value: JSON.stringify({
                title: title,
                url: url,
                message: gptMessage,
              }),
            },
          ],
        },
      ]
    : [];
  return JSON.stringify(attachments);
}

//エラー内容をSlackに投稿
function recordAndNotifyError(error) {
  let app = SlackApp.create(prop.SLACK_BOT_TOKEN);
  app.postMessage(prop.SLACK_CHANNEL_ID, error, {});
}

//投稿されたメッセージからURLを取得する
function extractUrls(text) {
  let urls = [];
  let regex = /(?:https?|http|ftp):\/\/[^\s>]+/gi;
  let match;

  while ((match = regex.exec(text)) !== null) {
    urls.push(match[0]);
  }
  return urls;
}

//URLからページのタイトルを取得する(取得できない場合は「no_title」)
function getWebpageTitle(url) {
  const noTitle = "no_title";
  if (!url) return noTitle;
  try {
    let response = UrlFetchApp.fetch(url);
    let content = response.getContentText();
    let titleRegex = /<title[^>]*>([\s\S]*?)<\/title>/gi;
    let matches = titleRegex.exec(content);
    let title = matches ? matches[1].trim() : noTitle;
    return title;
  } catch (e) {
    return noTitle;
  }
}

5. Webアプリとしてデプロイし、Webhook URLを取得する

  • GASの画面右上の「デプロイ」を押す
  • 新しいデプロイ > 種類の選択で「ウェブアプリ」を選択
  • アクセスできるユーザーを「全員」にし、デプロイ。

image

  • 次の画面から「ウェブアプリのURL」を取得。これがWebhook URLになる。(控えておく)

6. Slack botの作成②

3.で設定したSlack botのページに戻ります。

  • 左メニューのEvent Subscriptionsへ

  • 「Enable Events」をONにすると表示される「Request URL」に先ほど取得したウェブアプリのURLを貼り付け。

  • 同画面の「Subscribe to bot events」で「message.channels」を追加。
    image

  • 左メニューのInteractivity & Shortcutsへ

  • InteractivityをONにすると表示される「Request URL」に先ほど取得したウェブアプリのURLを貼り付け。

これで設定は以上です、お疲れ様でした。

正しく設定できていればbotをインストールしたチャンネルにメッセージを送信すると
①URLがあれば要約文
②URLがなければメッセージに沿ったテキスト
をスレッドに返信。
要約文を返した場合、ボタンを押すとnotionに記事のタイトル、URL、要約文を保存してくれます。

注意点

GASのコードを変更した場合

GASのコードを変更しそれをbotに反映する場合は以下の手順で毎回デプロイが必要です。

  • デプロイ > デプロイを管理 > 右上の鉛筆マークから編集
  • バージョンを「新しいバージョン」に設定しデプロイ

コードの補足

  • limitToken

    • chatGPT APIにリクエストする際のtoken数の上限を指定しています、
    • 簡易化のため、1文字=1tokenとしています。(3500はほぼMAX上限)
    • token料が多いほど利用料金は上がるのでよしなに設定してください。
    • chatGPT APIの料金体系は各自確認しておきましょう。
  • summaryTemplate

    • 要約させるプロンプトです、ここは自分なりに変えてみると納得する返答がもらえると思います。
    • また、英語の方が日本語よりもtoken数が少なくなる傾向にあるので英語で書くといいかもしれません。
    • 自分の場合は、以下のような形式をとっています。
    ・記事のタイトル
    ・全文の要約
    ・目次
    ・目次ごとの要約
    
  • getWebpageTitle

    • URLからページのタイトルを取得しています。(取得できない場合は「no_title」)
    • タイトルを取得している理由は要約の精度を上げるため。
    • 稀にリンクのみをAPIに渡すと全く関係ない要約を返答する時があります。その対策としてリンクとタイトルを同時に渡しています。
    • ただし、短縮URLなどの場合はタイトルが取得できず、意図した要約にならないケースがあります。

終わりに

遊びがてらに作ってみましたが、chatGPTの便利をとても感じることとなりました。
間違いなく開発のハードルが下がっているので、開発経験がない方もぜひチャレンジしてみてください。

Appendix

事前に作り方をchatGPTに質問してから開発し、エラーについても質問しながら進められました。
出力されたコードを参考に貼っておきます。

image

出力されたコード
chatgpt_output.js
function doPost(e) {
  var payload = JSON.parse(e.postData.contents);
  var message = payload.event.text;
  var url = extractUrlFromText(message);

  if (url) {
    var summary = generateSummary(url);
    sendSlackMessage(summary, url);
  }

  return ContentService.createTextOutput("");
}

function extractUrlFromText(text) {
  var regex = /(https?:\/\/[^\s]+)/g;
  var match = regex.exec(text);
  return match ? match[0] : null;
}

function generateSummary(url) {
  var apiKey = "YOUR_CHAT_GPT_API_KEY";
  var apiUrl = "https://api.openai.com/v1/engines/davinci-codex/completions";

  var data = {
    prompt: "Summarize this article: " + url,
    max_tokens: 50,
    n: 1,
    stop: ">>>",
  };

  var options = {
    method: "post",
    contentType: "application/json",
    headers: {
      "Authorization": "Bearer " + apiKey,
    },
    payload: JSON.stringify(data),
  };

  var response = UrlFetchApp.fetch(apiUrl, options);
  var summary = JSON.parse(response.getContentText()).choices[0].text.trim();

  return summary;
}

function sendSlackMessage(summary, url) {
  var token = "YOUR_SLACK_ACCESS_TOKEN";
  var channel = "#general";

  var blocks = [
    {
      "type": "section",
      "text": {
        "type": "mrkdwn",
        "text": summary,
      },
    },
    {
      "type": "actions",
      "elements": [
        {
          "type": "button",
          "text": {
            "type": "plain_text",
            "text": "Yes",
          },
          "style": "primary",
          "value": url,
          "action_id": "add_to_notion",
        },
        {
          "type": "button",
          "text": {
            "type": "plain_text",
            "text": "No",
          },
          "style": "danger",
          "action_id": "cancel",
        },
      ],
    },
  ];

  var options = {
    "method": "post",
    "headers": {
      "Authorization": "Bearer " + token,
      "Content-Type": "application/json",
    },
    "payload": JSON.stringify({
      "channel": channel,
      "blocks": blocks,
    }),
  };

  UrlFetchApp.fetch("https://slack.com/api/chat.postMessage", options);
}

function handleAction(payload) {
  var action = payload.actions[0];
  var value = action.value;
  var actionId = action.action_id;

  if (actionId == "add_to_notion" && value) {
    var url = value;
    var summary = generateSummary(url);
    var databaseId = "YOUR_NOTION_DATABASE_ID";
    var token = "YOUR_NOTION_API_KEY";

    var pageProperties = {
      "Name": {
        "title": [
          {
            "text": {
              "content": summary,
            },
          },
        ],
      },
      "URL": {
        "url": url,
      },
      "Summary": {
        "rich_text": [
          {
            "text": {
              "content": summary,
            },
          },
        ],
      },
    };

    var children = [
      {
        "object": "block",
        "type": "paragraph",
        "paragraph": {
          "text": [
            {
              "type": "text",
              "text": "This is the summary of the article:",
            },
            {
              "type": "text",
              "text": " ",
            },
            {
              "type": "text",
              "text": summary,
              "annotations": {
                "bold": true,
              },
            },
          ],
        },
      },
    ];

    var requestBody = {
      "parent": {
        "database_id": databaseId,
      },
      "properties": pageProperties,
      "children": children,
    };

    var options = {
      "method": "post",
      "headers": {
        "Authorization": "Bearer " + token,
        "Content-Type": "application/json",
        "Notion-Version": "2022-08-16",
      },
      "payload": JSON.stringify(requestBody),
    };

    var response = UrlFetchApp.fetch("https://api.notion.com/v1/pages", options);
    Logger.log(response.getContentText());
  }
}

Discussion

ShingoMisawaShingoMisawa

この記事を読んで実際にBotを作成することができました。
AI関連の記事を共有するチャンネルを運用していたのですが流れていってしまっていたのでNotionにストックしたかったところなので助かりました!ありがとうございます!

ただ、SlackBotを完成させるにはNotionAPIを叩く処理が不足していることに気づきました。

そして、Notionに追加ボタンを押した後の処理を動かすにはおそらくInteractiveの設定が抜けていると思われます。
最初にこの記事通りのコードで実装し、Notionに追加ボタンを押したところ、
Hmm, that didn't work, because this app is missing a request URL. Define one here:
というメッセージが表示されました。

私の場合はNotionに追加ボタンを押さずともURL共有しただけでNotionに追加するようにしてしまったため、詳細までは調べてません。