天使と悪魔のコメントでチャンネルを和ませるSlackボットの作り方

 こんにちは、みっつんです。
今日は、Slackの直近の投稿メッセージについて、天使と悪魔がコメントするSlackボットの作り方を紹介します。

 このSlackボットを作成することによって、コメントもらうための投稿が増えたり、コメントにくすっと笑ったり、チャンネルが和む感じがすること請け合いです!

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

天使と悪魔がコメントしてくれるSlackボット

目次

ボットのデザイン

ボットを作るに当たって実施したことを簡単に説明します。
まず最初に、チームメンバー集まってボットのキャラ付け、アイコン(イメージ画像)のすり合わせを行い、作るボットの方向性を共有しました。

キャラ付け

天使と悪魔のキャラをブレストし、そのなかから性格を次のように決めました。

天使:フレンドリーで体育会
悪魔:心が病んでて小悪魔的

キャラ付けワークショップ

アイコン作成

続いて、Slackに表示するアイコン(イメージ画像)を皆でAIで作成しました。
要約らっこの第2弾ということで、「らっこ」しばりで作成しました。

メンバー各自、自身がイメージする画像をどういったプロンプトでAIに指示するかの作業に夢中になりました。

プロンプト作成

要約ラッコのアプリを改修して、プロンプト変えながら実行し、出力コメントの確認を実施しました。
ところが、出力結果が安定せず、コメントの文章を指定したフォーマットにするために、「user」ロールでプロンプトの指示を与えた後で、「assistant」ロールで回答例を示しました。
改めて「user」ロールでプロンプトの指示を行うことにより、出力コメントの文章が安定することが分かりました。

messages=[
            {"role": "system", "content": "あなたはらっこ星人の天使で、句点を「らっこ!」に変換して話すらっこ語の話者です。チャットログのフォーマットは発言者: 本文\\nになっている。\\nは改行を表しています。これを踏まえて指示に従います。"},
            {"role": "user", "content": f"下記のチャットログの本文に対し、フレンドリーで体育会系のコメントしてください。「発言者さん」はいらないです。\n\n仕事中なのにすごく眠い"},
            {"role": "assistant", "content": f"天使:仕事中に眠くなっちゃったんですね。少しだけ休憩して、リフレッシュするのも大切らっこ!😇"},
            {"role": "user", "content": f"下記のチャットログの本文に対し、フレンドリーで体育会系のコメントしてください。「発言者さん」はいらないです。\n\n{text}"}
        ]

実装

実装方法は「1日の投稿内容を簡単にまとめてくれる要約Slackボットの作り方」の実装の章とまったく同じになり、
Slack Appのマニュフェストやアイコンの設定とボットのpythonコードだけ違う感じになります。

Slack の APIキーを取得する

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

要約ボットとは違うアイコンでメッセージを表示したいため、新しく 天使と悪魔の2つのSlack App を作ります。
なお、説明は天使の Slack App の作成のみになります。同じ手順で悪魔のイメージ画像を設定した Slack App も作成し、2つの 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」をクリックします。
※2つ目のデビルを作る場合は、「エンジェル」や「rakko angel」の部分を「デビル」や「rakko devil」に置換して貼り付けてください。

{
    "display_information": {
        "name": "エンジェルらっこ",
        "description": "Public channelの投稿にコメント",
        "background_color": "#4f2f44"
    },
    "features": {
        "bot_user": {
            "display_name": "rakko angel",
            "always_online": false
        }
    },
    "oauth_config": {
        "scopes": {
            "bot": [
                "channels:history",
                "channels:join",
                "channels:read",
                "chat:write",
                "users:read",
                "groups: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-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
APIキー取得

天使の登録が終わったら、同じ手順で悪魔の画像を登録した Slack App を作成しましょう。

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

ローカルで実行できる環境を作っていきます。
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/angel_devil$ python3 -V
Python 3.10.12

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

angel_devil
 ├ 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のキーを取得した値に書き換えてください。
    ※天使と悪魔でプログラムに差がないため、プログラムは1本にしています。Slack APIキーの「TOKEN_ANGEL」と「TOKEN_DEVIL」の値はそれぞれ別々に作成したSlack APIの値を設定してください。
  • ChatGPTを無償で利用の場合は、model=”gpt-3.5-turbo”に書き換えてください。
  • 処理対象のチャンネル名を変更してください。(”times_tanaka”,”times_suzuki”の箇所)

lambda_function.py

import os
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"

TOKEN_ANGEL = "xoxb-YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY"
TOKEN_DEVIL = "xoxb-ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ"

# 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()
        users = users_info['members']
        return users
    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 generate_message(text, event):
    try:
        prompt = "" 
        if event["is_angel"] :
            prompt=[
                {"role": "system", "content": "あなたはらっこ星人の天使で、句点を「らっこ!」に変換して話すらっこ語の話者です。チャットログのフォーマットは発言者: 本文\\nになっている。\\nは改行を表しています。これを踏まえて指示に従います。"},
                {"role": "user", "content": f"下記のチャットログの本文に対し、フレンドリーで体育会系のコメントしてください。「発言者さん」はいらないです。\n\n仕事中なのにすごく眠い"},
                {"role": "assistant", "content": f"仕事中に眠くなっちゃったんですね。少しだけ休憩して、リフレッシュするのも大切らっこ!😇"},
                {"role": "user", "content": f"下記のチャットログの本文に対し、フレンドリーで体育会系のコメントしてください。「発言者さん」はいらないです。\n\n{text}"}
            ]
        elif event["is_devil"] :
            prompt=[
                {"role": "system", "content": "あなたはらっこ星人の悪魔で、句点を「らっこ!」に変換して話すらっこ語の話者です。チャットログのフォーマットは発言者: 本文\\nになっている。\\nは改行を表しています。これを踏まえて指示に従います。"},
                {"role": "user", "content": f"下記のチャットログの本文に対し、心が病んでて小悪魔的なコメントしてください。「発言者さん」はいらないです。\n\n仕事中なのにすごく眠い"},
                {"role": "assistant", "content": f"眠いなら、こっそりと仕事をサボって小一時間寝るのも悪くないんじゃないかならっこ?😈"},
                {"role": "user", "content": f"下記のチャットログの本文に対し、心が病んでて小悪魔的なコメントしてください。「発言者さん」はいらないです。\n\n{text}"}
            ]
        else:
            logger.error("引数が適切ではありません。")
            return ""
            
        response = openai.ChatCompletion.create(
            model="gpt-4",
            temperature=0.5,
            messages=prompt
        )
        return response["choices"][0]["message"]['content']
    except Exception as e:
        logger.error(f"generating text: {e}")
        return ""

# メイン関数
def main(event):
    slack_token = "" 
    if event["is_angel"] :
        slack_token = TOKEN_ANGEL
    elif event["is_devil"] :
        slack_token = TOKEN_DEVIL
    else:
        logger.error("Argument is not appropriate.")
        return ""
    
    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']}")
        # 対象timesチャンネル以外は読み飛ばす
        if (channel["name"] != "times_tanaka" 
             and channel["name"] != "times_suzuki"):
            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 = ""
        for thread in threads["messages"]:
            if "bot_id" in thread:
                continue
            if thread["text"].strip() == '':
                logger.debug("Skip due to no target message.")
                continue
            messages_in_channel = thread["text"]
            break
        try:
            logger.debug(f"post message:{messages_in_channel}")
            if messages_in_channel != "":
                result_text.append(generate_message(messages_in_channel, event))
                logger.info(f"comment:{result_text}")
        except InvalidRequestError as e:
            result_text.append('Generation failed because 4096 tokens were exceeded.')
        if messages_in_channel != "":
            title = ""
            text = title.join(result_text)
            response = client.chat_postMessage(
                channel=channel["id"],
                text=text
            )
            logger.info(f"Message posted: {response['ts']}")
        else:
            logger.info("Since there were no messages posted, the process was skipped.")

# lambdaで初回に呼ばれる関数
def lambda_handler(event, context):
    logger.debug(f"event: {event}")
    logger.debug(f"context: {context}")
    main(event)

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

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({'is_angel':True,'is_devil':False})"

実行で、天使のコメントが表示されました。


続けて、悪魔も実行してみましょう。

実行(悪魔)
python3 -c "from lambda_function import main;main({'is_angel':False,'is_devil':True})"

実行で、悪魔のコメントが表示されました。

まとめ

 今回は直近のコメントに対して、天使と悪魔のキャラが反応してくれるボットを作成しました。
このボットを個人がつぶやく分報チャンネルにリリースしたところ、リアクションやコメントなどかなりの反応がありました。
その効果もあってか、反応が欲しくて、コメント投稿する人(チャットに餌をまく人)や、天使と悪魔がどんな反応してるんだろうと他のメンバーの分報を見る人が増えたり、見ると同時についつい自分自身もコメントやリアクション入れたり。といった効果がありました。
 Slack導入したけど、盛り上がってないなぁ。と思っているあなた。天使と悪魔、ぜひ試してみてください。