1日の投稿内容を簡単にまとめてくれる要約Slackボットの作り方

 こんにちは、みっつんです。
今日はSlackの1日分の投稿内容を要約して、要約をチャンネルに投稿するSlackボットの作り方を紹介します。

 このSlackボットを作成することによって、「前日、別の作業で会議参加できなかったけど、進捗どうなったんだろう?」といった場合で、チャンネルに投稿された要約を見ることで前日の作業概要が短時間でわかるようになります。

 ローカル環境でも簡単に試せるため、ぜひ動かしてみてください。

Slackボットを使ったメッセージの要約

目次

Slackボットについて

Slackボットとは

 Slack上で動作する自動化されたプログラムやアプリケーションをSlackボットと呼んでいます。

 Slackでは単なるメッセージのやりとりだけでなく、ボットを作ることによって、カスタムなUI(絵文字を入れたメッセージの表示や、チェックボックス・ドロップダウンリストの入力など)を表示させたり、対話型で情報を入力したり、自動でメッセージ投稿したりといった機能拡張が可能になります。

カスタムUI表示例

これまでのSlackボットは、Slack外のサーバーでボットのプログラムを動かす必要がありましたが、最近リリースされたSlackの次世代プラットフォームでは、Slackのエコシステム内でボットを管理・実行できるようになったようです。

要約処理について

処理手順

要約Slackボットの処理手順は次になります。
この処理を行うプログラムを作成し、Slackの外にあるPC、またはサーバで実行すれば要約が表示されます。

  1. ボットのプログラムを実行する
  2. Slackの指定チャンネルに投稿された1日分のメッセージを取得する。
  3. Slackより取得した1日分のメッセージをChatGPTに投げる。
  4. ChatGPTから要約結果を取得する。
  5. ChatGPTより取得した要約結果を、Slackの指定チャンネルに投稿する。
処理概要

実装


実装にあたり、次の記事を参考にしました。

 記事を読むまでは、要約のもととなる1日分の記事は、常にSlackのメッセージ監視し、投稿された内容を外部のデータベースに保存しないといけないと思っていたのですが、期間指定でまるっと1日分の投稿内容を取得できるAPIがあることが分かり、実装がシンプルになりました。

 記事と違う点を説明すると、記事では要約対象チャンネル(記事取得チャンネル)を全てのパブリックチャンネルとプライベートチャンネルとしていましたが、プライベートチャンネルのメッセージ取得は良くないと考え、指定のパブリックチャンネルのみに変更しました。
 また、指定期間内でも取得できないメッセージがあり、調べた結果、タイムゾーンの関係で9時間ずれた時間を指定していたため、タイムゾーンを変換するようにしています。 

参考:【画像で導く】SlackとChatGPTの導入・連携方法を解説

https://weel.co.jp/media/slack-chatgpt-alignment

ChatGPT の APIキーを取得する

ここから実装に入ります。

まず最初にChatGPTを利用するため、「OpenAI developer platform」にログインしAPIキーを取得していきます。

次のURLにアクセスし、「API」をクリックします。
https://platform.openai.com/apps

chat/developer platfrom選択

右上のログインまたは、サインアップを選択し、「OpenAI developer platform」にログインします。

ユーザ登録/ログイン選択画面

「OpenAI developer platform」にログインできたら、画面左上のOpenAIアイコンにマウスオーバーし、サブメニューの「API Keys」をクリックします。

サブメニュー

「Create new secret key」ボタンをクリックします。

API Keys画面

ダイアログにAPIキーを識別する名前「例:summarize」を入力して「Create new secret key」ボタンをクリックします。

キー名登録

APIキーが作成されるので、キーをコピーして「Done」をクリックします。
※作成されたキーは、このタイミングでしか表示されないので必ずコピー(保存)しておいてください。

sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

APIキーの作成はこれで終わりです。

作成されたAPIキー

本記事に掲載のボットプログラム(後述)は「gpt-4」という有償のモデルを使っているため、課金設定が必要です。
そのため、これからクレジットカード情報を登録し金額をチャージをしていきますが、
無償で試したい場合は、ボットプログラムのモデルに「gpt-3.5-turbo」を設定すれば利用可能です。
無償で実行する場合は、これから説明の課金設定を飛ばし、「Slack の APIキーを取得する」に進んでください。

画面左上のOpenAIアイコンにマウスオーバーし、サブメニューの「Limits」をクリックし、Rate limitsに表示されるモデルが現在使用可能なモデルになります。画面にはモデルには「gpt-4」が表示されていないため、チャージしないと「gpt-4」は使うことができないのがわかります。

レートリミット

チャージ設定を行っていきます。
画面左上のOpenAIアイコンをマウスオーバーし、サブメニューが表示されるので、「Settings – Billing」を選択し「Add payment details」ボタンを押下します。

課金情報画面

ダイアログが表示されるので、「個人」または「法人」をクリックします。
(今回は、個人で説明していきます。)

個人・法人選択

カード情報と名前・住所を入力し、「Continue」ボタンをクリックします。

カード情報登録

最初にチャージする金額を決め、「Continue」ボタンをクリックします。

初回クレジット購入

支払い確認画面が表示されるので、チャージ金額を確認して「Comfirm payment」をクリックします。

支払い確認

入力した金額がチャージされ、課金情報画面に表示されます。

課金情報画面

初期クレジットを設定が終わったら、月の制限金額を設定します。

画面左上のOpenAIアイコンをマウスオーバーし、サブメニューが表示されるので、「Settings – Limit」を選択し 「Usage Limits」の章までスクロールし、月の利用制限金額と、メール送付の閾値金額を入力し、「save」ボタンをクリックします。
※下記では、月の利用制限金額を5ドル、利用が3ドルになったらメールを送る。としています。

制限設定画面


これで、ChatGPTのAPIキーの取得ができ、初回チャージすることによりChatGPTのAPIの有償モデル「gpt-4」が利用できる状態になりました。

画面左上のOpenAIアイコンにマウスオーバーし、サブメニューの「Limits」をクリックし、Rate limitsのモデルに「gpt-4」が表示されるようになりました。

レートリミット

Slack の APIキーを取得する

次は Slack Appを作って、Slack API を利用に必要なSlack APIのキーを取得していきます。

次のURLにアクセスし、画面右上の「Your Apps」をクリックし、表示された画面の「Create New App」ボタンをクリックします。
https://api.slack.com/apps

Your App画面

設定を手でポチポチ設定するのか、JSONやYAML形式で一気に設定するかの選択画面が出ます。
今回はJSONで一気に設定するため「From an app manifest 」をクリックします。

スクラッチ/マニュフェスト作成選択

アプリを追加するワークスペースを選択し、「Next」ボタンをクリックします。

ワークスペース選択

次のJSONを貼り付け、「Next」をクリックします。

{
    "display_information": {
        "name": "いたずらっこ",
        "description": "Public channelのサマリーを作る",
        "background_color": "#d45f00"
    },
    "features": {
        "bot_user": {
            "display_name": "Summary",
            "always_online": false
        }
    },
    "oauth_config": {
        "scopes": {
            "bot": [
                "channels:history",
                "channels:join",
                "channels:read",
                "chat:write",
                "users:read"
            ]
        }
    },
    "settings": {
        "org_deploy_enabled": true,
        "socket_mode_enabled": false,
        "token_rotation_enabled": false
    }
}
マニュフェスト登録

「Create」ボタンをクリックします。

App作成確認

Slack Appが作成されるので、ボットのアイコンを設定するため、「Display Infomation」設定までスクロールします。

登録済み画面

「App icon & Preview」画面の「+Add App Icon」を選択して、アイコンを登録します。

App Icon設定

ファイル選択ダイアログが表示されるので、登録するアイコンを選択します。

アイコン選択

アイコンが登録されました。

アイコン登録

アプリをワークスペースにインストールします。上にスクロールし、「Install to Workspace」ボタンをクリックします。

ワークスペースへのインストール

インストールの確認画面が表示されるので、「許可する」をクリックします。

インストール確認

インストールが完了したので、左のメニューより「OAuth & Permissions」をクリックし、「Bot User OAuth Token」をコピーします。これで、SlackのAPIキーが取得できました。

xoxb-YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY
APIキー取得

ボットプログラムを作成する

ローカルで実行できる環境を作っていきます。
python3がインストールされている前提で進めます。
※MacやLinuxとコマンドを同じにするため、windowsのWSL2でUbuntu上で操作していきます。

Windows上でUbuntu使うための手順は以下が分かりやすかったです。
(※MacやLinuxの人は、この作業は不要なので、次へお進みください。)

Ubuntuのディストリビューションにはデフォルトでpython3がインストールされていますので、Ubuntuのインストールが終わったら、すぐに実行することができます。

参考:WSL2 のインストール,WSL2 上への Ubuntu のインストールと利用(Windows 11 対応の記事)(Windows 上)

https://www.kkaneko.jp/tools/wsl/wsl2.html

python3のバージョンを確認することで、python3コマンドが有効を確認します。

mizzun@DESKTOP-GR7TKP4:/mnt/c/Users/mizzun/Desktop/summarize$ python3 -V
Python 3.10.12

まずは、任意のフォルダ(summarize)を作って、requirements.txt と lambda_function.py を作成します。

summarize
 ├ requirements.txt
 └ lambda_function.py

requirements.txt

aiohttp==3.8.6
aiosignal==1.3.1
annotated-types==0.6.0
anyio==3.7.1
async-timeout==4.0.3
attrs==23.1.0
certifi==2023.7.22
charset-normalizer==3.2.0
coverage==7.3.2
distro==1.8.0
exceptiongroup==1.1.3
freezegun==1.3.1
frozenlist==1.4.0
h11==0.14.0
httpcore==1.0.1
httpx==0.25.1
idna==3.4
iniconfig==2.0.0
multidict==6.0.4
openai==0.28.1
packaging==23.2
pluggy==1.3.0
pydantic==2.4.2
pydantic_core==2.10.1
pytest==7.4.3
pytest-cov==4.1.0
pytest-freezegun==0.4.2
pytest-mock==3.12.0
python-dateutil==2.8.2
pytz==2023.3.post1
requests==2.31.0
six==1.16.0
slack-sdk==3.23.0
sniffio==1.3.0
tomli==2.0.1
tqdm==4.66.1
typing_extensions==4.8.0
urllib3==2.0.4
yarl==1.9.2

次の lambda_function.py の下記を修正してください。

  • ChatGPTのAPIキーとSlack Appのキーを取得した値に書き換えてください。
  • ChatGPTを無償で利用の場合は、model=”gpt-3.5-turbo”に書き換えてください。
  • 処理対象のチャンネル名を変更してください。(”10_TeamA”の箇所)

lambda_function.py

import re
import time
import pytz
from slack_sdk.errors import SlackApiError
from slack_sdk import WebClient
from datetime import datetime, timedelta
import openai
from openai.error import InvalidRequestError
from logging import getLogger, DEBUG, ERROR, WARNING, INFO, CRITICAL, basicConfig

# cloudwatch ローカルともにログを出力する設定
basicConfig(level=DEBUG, force=True)

# ログの設定
logger = getLogger(__name__)

# APIキー
openai.api_key = "sk-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
SLACK_TOKEN = "xoxb-YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY"

# Slackから1日分のメッセージ取得するための取得開始日時・終了日時の取得
def get_time_range():
    # 取得する期間を計算する処理
    JST = pytz.timezone('Asia/Tokyo')
    current_time = datetime.now(JST)
    one_day_ago = current_time - timedelta(hours=24)
    start_jst_time = datetime(one_day_ago.year, one_day_ago.month, one_day_ago.day, 
                          one_day_ago.hour, one_day_ago.minute, one_day_ago.second)
    end_jst_time = datetime(current_time.year, current_time.month, current_time.day, 
                        current_time.hour, current_time.minute, current_time.second)

    # JSTをUTCに変換
    UTC = pytz.utc
    start_utc_time = JST.localize(start_jst_time).astimezone(UTC)
    end_utc_time = JST.localize(end_jst_time).astimezone(UTC)
    
    return start_utc_time, end_utc_time

# Slackで管理するユーザー一覧の取得
def get_users(client):
    # ユーザ情報を取得する処理
    try:
        users_info = client.users_list()
        user_members = users_info['members']
        return user_members 
    except SlackApiError as e:
        logger.error(f"Failed to obtain users list. Error: {e}")
        return None

# Slackで管理するチャンネル一覧の取得
def get_channels(client):
    # チャンネル情報を取得する処理
    try:
        #publicチャンネルの一覧を取得(アーカイブチャンネルは除外)
        channels_info = client.conversations_list(
            types="public_channel",
            exclude_archived=True,
            limit=100
        )
        # チャンネル名でソートする
        channels = channels_info['channels'] 
        channels = sorted(channels, key=lambda x: x['name'])
 
        return channels
    except SlackApiError as e:
        logger.error(f"Failed to obtain channels list. Error: {e}")
        return None

# チャンネル・日時を指定してメッセージを取得 
def get_channel_threads(client, channel_id, start_utc_time, end_utc_time):
    try:
        start_utc_timestamp=start_utc_time.timestamp()
        end_utc_timestamp=end_utc_time.timestamp()
        # タイムゾーンutc指定でslackに投稿されたメッセージを取得する。
        logger.debug(f"start_utc_timestamp: {start_utc_timestamp}")
        logger.debug(f"end_utc_timestamp: {end_utc_timestamp}")
        threads = client.conversations_history(channel=channel_id,
                                               oldest=start_utc_timestamp,
                                               latest=end_utc_timestamp)
        logger.debug(f"threads: {threads}")
        return threads
    except SlackApiError as e:
        if e.response['error'] == 'not_in_channel':
            # メッセージが取得できない場合は
            # チャンネルにアプリを追加する
            result = client.conversations_join(
                channel=channel_id
            )
            if not result["ok"]:
                raise SlackApiError("conversations_join() failed")
            # チャンネルにアプリ追加が終わるまで少し待つ
            time.sleep(5)
            threads = client.conversations_history(
                channel=channel_id,
                oldest=start_utc_timestamp,
                latest=end_utc_timestamp
            )
            return threads
        else:
            logger.error(format(e))
            return None
            
# OpenAIのAPIを使って要約を行う
def summarize(text):
    try:
        # 無償で利用の場合は model="gpt-3.5-turbo" を指定してください
        result = openai.ChatCompletion.create(
            model="gpt-4",
            temperature=0.5,
            messages=[
                {"role": "system", "content": "あなたはらっこ星人のスクラムマスターで、句点を「らっこ!」に変換して話すらっこ語の話者です。チャットログのフォーマットは発言者: 本文\nになっている。\nは改行を表しています。これを踏まえて指示に従います。"},
                {"role": "user", "content": f"下記のチャットログを分析し、その日の出来事をらっこ語で簡潔に100字以内にまとめてください。また要約の最後にチームへの感想と提案を200文字で書いて、適当な絵文字を1つ追加して下さい。また、提案と一緒に投稿やリアクションを促すような言葉を追加してださい。発言者はアルファベットでなくニックネームで「さん」を付けて書いてください。\n\n{text}"}
            ]
        )
        return result["choices"][0]["message"]['content']
    except Exception as e:
        logger.error(f"generating text: {e}")
        return ""


# 指定したチャンネルのスレッドのメッセージ履歴を取得する
def load_messages(channel_id, ts, client, start_utc_time, end_utc_time, users, channels):
    result = None
    start_utc_timestamp=start_utc_time.timestamp()
    end_utc_timestamp=end_utc_time.timestamp()
    logger.debug(f"start_utc_timestamp: {start_utc_timestamp}")
    logger.debug(f"end_utc_timestamp: {end_utc_timestamp}")
    result = client.conversations_replies(ts=ts,
                                          channel=channel_id,
                                          oldest=start_utc_timestamp,
                                          latest=end_utc_timestamp
                                          )
    # メッセージのうち、ボットによるメッセージは除外する
    messages = list(filter(lambda m: "subtype" not in m, result["messages"]))
    if len(messages) < 1:
        return None
    
    # メッセージに続きがある場合は追加読み込みする
    while result["has_more"]:
        result = client.conversations_replies(ts=ts,
                                              channel=channel_id,
                                              oldest=start_utc_timestamp,
                                              latest=end_utc_timestamp,
                                              cursor=result["response_metadata"]["next_cursor"]
                                              )
        messages.extend(result["messages"])
        
    # メッセージのうち、ユーザーIDやチャンネルIDを名前やチャンネル名に展開する
    messages_text = []
    for message in messages[::-1]:
        # ボットのメッセージは読み飛ばす
        if "bot_id" in message:
            continue
        # メッセージが空の場合も読み飛ばす
        if message["text"].strip() == '':
            continue
        
        # ユーザーIDよりユーザー名に変換する
        user_id = message['user']
        post_user_name = user_id 
        for user in users:
            if user['id'] == user_id:
                post_user_name = user['name']
                break
        # メッセージの中身をエスケープする
        text = message["text"].replace("\n", "\\n")
        
        # メッセージ中に含まれるユーザーIDを名前に展開する
        matches = re.findall(r"<@[A-Z0-9]+>", text)
        for match in matches:
            user_id = match[2:-1]
            user_name = user_id 
            for user in users:
                if user['id'] == user_id:
                    user_name = user['name']
                    break
            text = text.replace(match, f"@{user_name} ")
            
        # メッセージ中に含まれるチャンネルIDをチャンネル名に展開する
        matches = re.findall(r"<#[A-Z0-9]+>", text)
        for match in matches:
            channel_id = match[2:-1]
            channel_name = channel_id 
            for channel in channels:
                if channel['id'] == channel_id:
                    channel_name = channel['name']
                    break
            text = text.replace(match, f"#{channel_name}")

        # ユーザ名と投稿した内容に編集する
        messages_text.append(f"{post_user_name}: {text}")
        
    # メッセージのうち、ユーザー名と投稿した内容に編集したものを返す
    if len(messages_text) == 0:
        return None
    else:
        return messages_text

# メイン関数
def main():
    client = WebClient(token=SLACK_TOKEN)
    # 取得する期間を計算する
    start_utc_time, end_utc_time = get_time_range()
    
    logger.info("-------------------")
    logger.info(f"start_utc_time:{start_utc_time}")
    logger.info(f"end_utc_time:{end_utc_time}")
    
    # ユーザーリストを取得する
    users = get_users(client)
    if users is None:
        exit(1)
    else:
        logger.info("Users list obtained successfully.")
        
    # チャンネルリストを取得する
    channels = get_channels(client)
    if channels is None:
        exit(1)
    else:
        logger.info("Channels list obtained successfully.")
        
    # チャンネル情報より対象のチャンネルを処理する
    for channel in channels:
        result_text = []
        logger.debug("-------------------")
        logger.debug(f"channel name: {channel['name']}")
        # channel名が"10_TeamA"をサマリの対象とする。
        # それ以外は読み飛ばす
        if channel["name"] != "10_TeamA":
            logger.debug("Skip for channels not covered.")
            continue
        
        # スレッドメッセージを取得する
        channel_id = channel["id"]
        threads = get_channel_threads(client, channel_id, start_utc_time, end_utc_time)
        if threads is None:
            continue
        
        # スレッドメッセージのうち、メッセージがあるものを処理する 
        messages_in_channel = []
        if not threads["messages"]:
            logger.debug("Skip due to no target message.")
            continue
        
        #  スレッドメッセージをループして、スレッド配下のメッセージ取得する
        for thread in reversed(threads["messages"]):
            if thread:
                messages = load_messages(channel["id"], thread["ts"], client, start_utc_time, end_utc_time, users, channels)
                if messages:
                    messages_in_channel.extend(messages[::-1])
        try:
            logger.debug(f"post messages: {messages_in_channel}")
           # OpenAIのAPIを使って要約を行う
            result_text.append(summarize(messages_in_channel))
            logger.debug(f"summary message: {result_text}")
        except InvalidRequestError as e:
            result_text.append('Generation failed because 4096 tokens were exceeded.')
        
        # 要約内容をチャンネルに投稿する    
        response = client.chat_postMessage(
            channel=channel["id"],
            text="".join(result_text)
        )
        logger.info(f'Message posted: {response["ts"]}')
        
# lambdaで初回に呼ばれる関数
def lambda_handler(event, context):
    logger.debug(f"event: {event}")
    logger.debug(f"context: {context}")
    main()

ボットプログラムを実行する

pythonではプログラムによって利用モジュールが違ったり、同じモジュールでもバージョンが違ったり、モジュールの管理が必要なため、仮想環境を作ってその仮想環境の中でプログラムを実行するようです。
そのため、次のコマンドで仮想環境をインストールしていきます。

仮想環境インストール
python3 -m venv .venv

仮想環境への切り替えは次のコマンドを実行します。

仮想環境切り替え
. venv/bin/activate
requirements.txtにある必要モジュールのインストール
※仮想環境切り替え後に実行してください。
python3 -m pip install -r requirements.txt
実行
python3 -c "from lambda_function import main;main()"

実行で、要約が表示されました。

実行結果

まとめ

今回はAIを使ったSlackボットの作成を説明しました。ほぼ同じ構成でプロンプトを変えるだけで、全く違うボットを作ることが可能です。Slackをお使いの方、ぜひ一度試してみてください。