素人が大会を盛り上げるSlackボットをDifyで作ってみた

# はじめに
[技術チャレンジ部](https://challenge-club.connpass.com/)のとも(Tomo)です。チームTPACで[自動運転AIチャレンジ2024](https://www.jsae.or.jp/jaaic/2024ver/summary/)に[参加中](https://www.jsae.or.jp/jaaic/2024ver/contest/)です。
[こちらの記事](https://qiita.com/tomo-v/items/6aeec8c27cc5586d4fa8)で作ってみたDiscordのボットを、Slackに移植してみました。

https://qiita.com/tomo-v/items/6aeec8c27cc5586d4fa8

素人なので、変なことを言っていたらすいません!

# 設計方針
Discordで作ったボットのアーキテクチャをなるべく流用するために、以下の方針としました。
- PythonのライブラリからSlack APIを使用する
- Pythonスクリプトがクライアントになる

# Slackのボット
## フレームワーク
Slackのボットには様々な実装方法があるようです。[Slackの開発者ページ](https://api.slack.com/lang/ja-jp)では、Boltという公式フレームワークがトップで紹介されています。
Boltには、Python版であるBolt for Pythonがあるので、これを使用してみます。
[Bolt入門ガイド](https://slack.dev/bolt-python/ja-jp/getting-started/)では、ソケットモードが使用されています。ソケットモードでは、[HTTPでは必要なRequest URLのエンドポイント](https://slack.dev/bolt-python/ja-jp/tutorial/getting-started-http/#setting-up-events)を用意しないで済みそうなので、設計方針にも合いそうです。
 また、入門ガイドには、
> ソケットモードは、Slack アプリ開発をとりあえず始めてみるときやあなたのチームだけのためのアプリをつくるときにおすすめのやり方です。

とあり、お試しにはちょうどよさそう。この方法では、LegacyやClassicな仕組みもうまく避けられそうです。

## Slackアプリの作成
[Bolt入門ガイド](https://slack.dev/bolt-python/ja-jp/getting-started/)に沿って作ってみます。
- アプリの作成
  - Create New App -> From scrachでボット名とワークスペースを設定
- ボットトークンへのスコープの追加
  - OAuth & Permissions -> Bot Token Scopes -> Add an Oauth Scopeで以下を追加
   - `chat:write` …メッセージの投稿に必要
   - `app_mentions:read` …メンション内容の取得に必要
- アプリレベルトークンの取得
  - Basic Information -> App-Level Tokens -> Generate Token and Scopesで以下を実施
    - Token Nameにトークンの名前を入力
    - Add Scopeで`connections:write` を追加 …ソケットモードに必要
    - Generateボタンでアプリレベルトークンを生成し、メモ
- ソケットモードの有効化
  - Socket Mode -> Connect using Socket Mode -> Enable Socket Modeをオン
- イベント取得の設定
  - Event Subscriptions -> Enable Eventsをオンにする
  - Subscribe -> Add Bot User Eventで、`app_mention` を追加 …メンションイベントの取得に必要
- アプリのワークスペースへのインストールとボットトークンの取得
  - OAuth & Permissions -> Install App to Workspaceでアプリをインストール
    - Bot User OAuth Access Tokenをメモ
- 必要に応じてアイコンや名前の見直し
  - Basic Information -> Display Informationなどの見直しと変更を行い、Save Changesで保存

作ってみて分かったのですが、メンションイベント取得時に、メッセージ内容も取得できるようです。

# Pythonスクリプト
## Boltパッケージのインストール
```shell
pip install slack_bolt
```

## Discord用に使ったスクリプトの修正
[Discordの記事](https://qiita.com/tomo-v/items/6aeec8c27cc5586d4fa8)で使ったPythonスクリプトを修正します。
主な修正点は以下の通りです。
- DiscordのAPIを呼び出す部分をBolt for Slackに置き換える
  - Bolt for Slack化の大枠は、[Bolt入門ガイドの「メッセージをリッスンして応答する」](https://slack.dev/bolt-python/ja-jp/getting-started/#listening-and-responding-to-a-message)をベースにする
  - イベント取得部分は、[Bolt for Pythonの「イベントのリスニング」](https://slack.dev/bolt-python/ja-jp/concepts/event-listening)を参考にする
  - Discordと同じく非同期で扱えるように、[Bolt for Pythonの「ソケットモードの利用」](https://slack.dev/bolt-python/ja-jp/concepts/socket-mode)を参考に、importやmainを修正する

素人が一番とまどったのは、非同期の部分です。[Bolt for Pythonの「ソケットモードの利用」](https://slack.dev/bolt-python/ja-jp/concepts/socket-mode)の「Async (asyncio) の利用」には、

> aiohttp のような asyncio をベースとしたアダプターを使う場合、アプリケーション全体が asyncio の async/await プログラミングモデルで実装されている必要があります。AsyncApp を動作させるためには AsyncSocketModeHandler とその async なミドルウェアやリスナーを利用します。

とあり、Discordの時もこのようになっていたのですね。今回はAsyncSocketModeHandlerが使用できそうです。

また、Slack APIでは、ソケットモードでも3秒以内の応答が必要とのことで、LLMの長い生成時間を考えると、まずはack()で応答を返す必要がありそうです。(ということに記事を書きながら気づいた…書いてよかった…)
これは非同期でも対応できるとのことで、[こちらの記事](https://qiita.com/seratch/items/6d142a9128c6831a6718)が大変参考になりました。

これらの情報とChatGPTの支援を受けて修正したPythonスクリプトがこちらです。

```python
import asyncio
import os
from slack_bolt.async_app import AsyncApp
from slack_bolt.adapter.socket_mode.aiohttp import AsyncSocketModeHandler
from dotenv import load_dotenv
import aiohttp

load_dotenv()
DIFY_KEY = os.getenv("DIFY_API_KEY")

app = AsyncApp(token=os.environ.get("SLACK_BOT_TOKEN"))

@app.event("app_mention")
async def handle_app_mention_events(event, say, ack):
    await ack()

    user = event['user']
    query = event['text']
    url = 'http://localhost/v1/chat-messages'
    headers = {
        'Authorization': f'Bearer {DIFY_KEY}',
        'Content-Type': 'application/json'
    }
    data = {
        'query': query,
        'response_mode': 'blocking',
        'user': user,
        'conversation_id': '',
        'inputs': {}
    }

    async with aiohttp.ClientSession() as session:
        async with session.post(url, headers=headers, json=data) as response:
            if response.status == 200:
                json_response = await response.json()
                answer = json_response.get('answer', 'No answer provided.')
                await say(answer)
            else:
                error_message = await response.text()
                await say(f'APIエラー: {response.status}, メッセージ: {error_message}')

async def main():
    handler = AsyncSocketModeHandler(app, os.environ["SLACK_APP_TOKEN"])
    await handler.start_async()

if __name__ == "__main__":
    asyncio.run(main())
```

ファイル.envには、Slackのボットトークンを`SLACK_BOT_TOKEN`に、Slackのアプリレベルトークンを`SLACK_APP_TOKEN`に、DifyのAPIキーを`DIFY_API_KEY`に、それぞれ設定しておきます。

# DifyのSystemプロンプト
みなさまをサポートできるように、少しだけ、こんなふうに変えてみました。
<img width="350" alt="dify-system-prompt.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3764521/1341523a-9461-0730-4abe-cbab216ba1a1.png">

# 実行
ボットをテキストチャンネルに招待する必要があるようです。とりあえずメンションすると招待できたので、これで招待しました。
そして、聞いてみると…

![slack-chat.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3764521/ac7651cb-365f-09d2-499c-9d055ee760d5.png)

アツい!

# 今後に向けて
Slackの表記は、Markdownとは異なる[`mrkdwn`](https://api.slack.com/reference/surfaces/formatting)だそうなので、これに対応させてみたいですね。
今回はOpenAI API以外はオンプレ前提で、実行環境も一般のご家庭のため、SLAは一般のご家庭品質です。FaaS化すると、アーキテクチャが大幅に変わりそうですが、勉強になりそうです。
あとは、先ほどのtpac_gorillaくんのコメントにもありましたが、大会を通じて貴重な経験を楽しめればと思います!