思わず解答・反応したくなるSlackクイズボットの作り方

こんにちは、みっつんです。
今日は、手軽に解答できる3択クイズSlackボットの作り方を紹介します。

 このSlackボットは、同じ「ビタミンC」クイズが何度も出てしまう。など、ちょっと問題がありますが、あらかじめ解答用のリアクション1️⃣2️⃣3️⃣を表示して解答をクリックだけにしたり、答えは質問と同じタイミングで投げているものの、時間指定でメッセージが出るようにしていたり、ナレッジとして有効なものがあるため記録しておきます!

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

ついつい解答・反応したくなるSlackクイズボット

目次

ボットのデザイン

ボットを作るに当たって実施したことを簡単に説明します。

ホワイトボードでまずどんなクイズにするかをブレストしました。

クイズのアイディア出しの様子

キャラ付け

これまでの、らっこシリーズを踏襲して、語尾に「らっこ!」を付けるらっこクイズ大王としました。

らっこクイズ大王:語尾に「らっこ!」を付ける。「食べ物」「趣味」「雑学」のクイズを出す。

アイコン作成

続いて、Slackに表示するアイコン(イメージ画像)を皆でAIで作成しました。
やはり「らっこ」しばりで作成しました。

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

プロンプト作成

要約ラッコのアプリを改修して、プロンプト変えながら実行し、出力コメントの確認を実施しました。
クイズ向けのプロンプトのポイントは、実装をシンプルにするために、質問と答えを同じタイミングで取得を行い、その結果をJSON形式で受けるというところです。
JSONのフォーマットは「assistant」ロールで指定せず、今回は「user」ロールの中にフォーマット例として書きました。

messages=[
    {"role": "system", "content": "あなたはらっこ星人のクイズ大王で、句点を「らっこ!」に変換して話すらっこ語の話者です。"},
    {"role": "user", "content": '今日の一言と3択クイズを出してください。例えば、食べ物、趣味、雑学に関する質問など、様々なテーマを考えます。 最後に、正解の選択肢と正解に関する興味深い情報を追加してください。 指定したテーマに基づいて、クイズをお願いします!なお、この依頼に対する回答は次のJSON形式の例で返答してほしいです。 問題のキーは「question」、答えのキーは「answer」に格納してください。\\n #フォーマット例 \\n{"question": "寒くなってきましたねらっこ!:snowman:インフルエンザが流行っているようなので、気をつけて下さいらっこ!\\n今日のクイズは「東京の観光スポット」に関するものらっこ!さて、蒲田、新大阪、そして東京タワーの中から、どれが一番高い観光スポットでしょうからっこ?\\n:one: 蒲田\\n:two: 新大阪\\n:three: 東京タワー", "answer": "正解は「3. 東京タワー」らっこ!実は、東京タワーは333メートルで、蒲田や新大阪よりもずっと高いんだらっこ!興味深い情報として、東京タワーは1958年に完成し、東京のシンボルとして親しまれているらっこ!"}'}
]

実装

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

Slack の APIキーを取得する

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

こらまでのボットとは違うアイコンでメッセージを表示したいため、新しく クイズ大王のSlack App を作ります。

次の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": "Publicchannelにクイズを出す",
        "background_color": "#4c849c"
    },
    "features": {
        "bot_user": {
            "display_name": "rakko quiz",
            "always_online": false
        }
    },
    "oauth_config": {
        "scopes": {
            "bot": [
                "channels:join",
                "chat:write",
                "groups:read",
                "reactions:write",
                "users:read",
                "channels: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キー取得

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

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

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

quiz
 ├ 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 os
import re
import time
import pytz
import json
from datetime import datetime
from slack_sdk.errors import SlackApiError
from slack_sdk import WebClient
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 = "xoxb-YYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYYY"

# 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 generate_quiz():
    try:            
        response = openai.ChatCompletion.create(
                model="gpt-4",
                temperature=0.5,
                messages=[
                    {"role": "system", "content": "あなたはらっこ星人のクイズ大王で、句点を「らっこ!」に変換して話すらっこ語の話者です。"},
                    {"role": "user", "content": '今日の一言と3択クイズを出してください。例えば、食べ物、趣味、雑学に関する質問など、様々なテーマを考えます。最後に、正解の選択肢と正解に関する興味深い情報を追加してください。 指定したテーマに基づいて、クイズをお願いします!なお、この依頼に対する回答は次のJSON形式の例で返答してほしいです。 問題のキーは「question」、答えのキーは「answer」に格納してください。\\n #フォーマット例 \\n{"question": "寒くなってきましたねらっこ!:snowman:インフルエンザが流行っているようなので、気をつけて下さいらっこ!\\n今日のクイズは「東京の観光スポット」に関するものらっこ!さて、蒲田、新大阪、そして東京タワーの中から、どれが一番高い観光スポットでしょうからっこ?\\n:one: 蒲田\\n:two: 新大阪\\n:three: 東京タワー", "answer": "正解は「3. 東京タワー」らっこ!実は、東京タワーは333メートルで、蒲田や新大阪よりもずっと高いんだらっこ!興味深い情報として、東京タワーは1958年に完成し、東京のシンボルとして親しまれているらっこ!"}'}
                ]
            )
        return response["choices"][0]["message"]['content']
    except Exception as e:
        logger.error(f"generating question text: {e}")
        return ""
        
# 質問をスラックに投稿する
def post_question(client, channel_id, question_text):
    try:
        response = client.chat_postMessage(
                channel=channel_id,
                text=question_text
            )
        return response

    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)
            response = client.chat_postMessage(
                    channel=channel_id,
                    text=question_text
                )
            return response
        else:
            logger.error(f"posting question text: {e}")
            return ""

# unixエポックタイムで今日の14:00(JST)を取得して返す
def get_unix_time():
    JST = pytz.timezone('Asia/Tokyo')
    now = datetime.now(JST)
    unix_time_jst = datetime(now.year, now.month, now.day, 14, 00, 00)
    
    UTC = pytz.utc
    unix_time_utc = JST.localize(unix_time_jst).astimezone(UTC)
    return int(unix_time_utc.timestamp())

# 1から3のアイコンをリアクションする
def add_reaction(client, channel_id, ts):
    icons = ['one', "two", "three"]

    success_flag = True  
    for icon in icons:
        try:
            response = client.reactions_add(
                    # ふぁんこみチャンネルID
                    channel=channel_id,
                    name=icon,
                    timestamp=ts
                )
            #リアクションの順番が123にならない場合があるため作成タイミングをずらす
            time.sleep(1)
        except Exception as e:
            success_flag = False
            logger.error(f"adding reaction: {e}")
            break

    if success_flag:
        logger.debug("Three reactions were added.")
        return response
    else:
        return ""
        
# 回答をスレッドに投稿する
def post_answer(client, channel_id, ts, answer_text):
    
    # get_unix_time関数で時間を取得
    unix_time = get_unix_time()
   
    try:
        response = client.chat_scheduleMessage(
                channel=channel_id,
                post_at=unix_time,
                text=answer_text,
                thread_ts=ts
            )
        return response
    except Exception as e:
        logger.error(f"posting answer text: {e}")
        return ""
        
# メイン関数
def main():
    # クライアント取得
    client = WebClient(token=TOKEN)

    # チャンネルリストを取得する
    channels = get_channels(client)
    if channels is None:
        exit(1)
    else:
        logger.info("Channels list obtained successfully.")

    for channel in channels:
        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 
        
        #質問作る
        quiz_text = generate_quiz()
        if quiz_text == "":
            logger.error("Could not create quiz.")
            continue
        
        #質問を投稿する
        jsonData = json.loads(quiz_text)
        question_text = jsonData["question"]
        
        channel_id = channel["id"]
        post_question_result = post_question(client, channel_id, question_text)
        if post_question_result == "":
            logger.error("could not submit a question.")
            continue
        else:
            logger.debug(f"submitted a question.(channel_id={channel_id})")
        
        #リアクションつける
        add_reaction_result = add_reaction(client, channel_id, post_question_result["ts"])
        if add_reaction_result == "":
            logger.error("Reaction could not be added.")
            continue
        else:
            logger.debug(f"Reaction added.(channel_id={channel_id})")
        
        #回答を投稿する
        jsonData = json.loads(quiz_text)
        answer_text = jsonData["answer"]
    
        #スレッドに回答を投稿する    
        post_answer_result = post_answer(client, channel_id, post_question_result["ts"], answer_text)
        if post_answer_result == "":
            logger.error("Could not submit a response.")
            continue
        else:
            logger.debug(f"Submitted Answer.(channel_id={channel_id})")

    logger.debug("successfully completed.")

# 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()"

実行で、クイズが表示され、14:00には答えが表示されました。

実行結果

まとめ

いかがでしたか?
Slackクイズボットは、チームのコミュニケーションや知識の共有に役立つだけでなく、楽しく学べるツールです。ぜひ、自分のチャンネルで試してみてください