Dify のチャットワークフローを Slack Bot 化してみる

# やりたいこと
Dify (https://github.com/langgenius/dify) で作成したチャットワークフローを Slack から使えると便利だなと思い、実際にやってみたので記事にしてみます。


## Dify とは？
ChatGPT に聞いてみました。

```text
​Difyは、プログラミングの知識がなくても直感的にAIアプリケーションを開発できるオープンソースのプラットフォームです。 非エンジニアやビジネスユーザーでも、ノーコードでチャットボットやコンテンツ生成ツールなどの高度なアプリを構築できます。 

Difyの主な特徴:
直感的なノーコードUI: ドラッグ&ドロップ操作でアプリケーションを構築でき、初心者でも手軽にAIアプリの開発が可能です。 ​
多彩なAIモデルへの対応: OpenAI、Anthropic、Hugging Faceなど、さまざまなAIモデルプロバイダーをサポートしており、ユーザーのニーズに合わせて最適なモデルを選択できます。 ​
RAGエンジンによる高度な機能: 外部データソースから必要な情報を効率的に取り込み、高精度な生成処理を実現します。 ​
豊富なテンプレートとコンポーネント: チャットボット、タスクリスト、カレンダーなど、よく使われる機能をテンプレートとして提供しているため、ゼロからの開発は不要です。 ​

Difyの利用方法:
クラウドでの利用: Difyの公式サイトにアクセスし、アカウントを作成することで、ブラウザ上で手軽に利用を開始できます。 ​
ローカル環境での利用: オープンソースソフトウェアとして提供されているため、GitHubからリポジトリをクローンし、ローカル環境にインストールして利用することも可能です。 

Difyの料金体系:
Difyは、無料プランから本格開発向けの有料プランまで、幅広い料金体系を提供しています。 無料プランでも基本的な機能を利用できますが、商用利用や高度な機能を必要とする場合は、有料プランの検討が必要です。
商用利用時の注意点:
Difyは基本的に商用利用が可能ですが、マルチテナントSaaSサービスの提供やロゴ・著作権情報の変更など、特定のケースでは商用ライセンスの取得が必要となります。 

活用事例:
チャットボットの作成: Difyを活用して、企業独自のナレッジベースと連携した高度なチャットボットを開発することができます。 ​
テキスト生成: マーケティングコピーの作成、製品説明文の自動生成、レポート作成支援など、さまざまなビジネスシーンで活用できるテキスト生成ツールを構築できます。 ​
Difyは、AI開発の敷居を下げ、誰もが手軽に高度なAIアプリケーションを構築できるプラットフォームとして注目されています。 プログラミングの壁を越えて、誰もがAIの力を活用できる時代の到来を感じさせるDifyの世界をご覧ください。 ​
```

回答してくれている通りですね。生成 AI アプリケーションをローコード・ノーコードで構築するためのプラットフォームです。
[SaaS 版](https://dify.ai/) と [OSS 版](https://github.com/langgenius/dify) があり、基本的には OSS の内容が SaaS でも提供されているようです。
OSS は開発が活発で、だいたい毎週新しいバージョンがリリースされています。公式 Discord もあり、議論が活発に行われていたり、困りごとを誰かが回答してくれていたりします。

## 環境
* Slack bot
* Dify: 0.15.3
    * localhost ではなくインターネットからアクセスできる必要があります
    * SaaS 版を利用するか、AWS などにセルフホストしインターネットに公開してください
* 何らか API サーバ
    * 今回は Azure Functions App を使います (他の FaaS でも 別途サーバを立てても可)
    * ファイル保存用に Azure Storage Account もつかいます

## 構成

ざっと以下のような流れ。
今回はチャットワークフローを利用するので、Dify 側で発行される conversation_id を保持しておく必要があり、Azure の Storage Account で Slack の thread_ts と conversation_id とを紐づけて保存している。

```mermaid
sequenceDiagram
    participant bot as Slack
    participant func as Azure Functions App
    participant sa as Storage Account
    participant dify
    bot->>bot: app mentioned
    bot->>func: send event
    func->>func: parse event json
    func->>sa: get conversation id by thread_ts
    sa->>func: conversation id
    func->>dify: call workflow
    dify->>func: workflow result
    func->>sa: save conversation id if not exist
    func->>bot: post a message
```

## (Optional) Slack ワークスペースの作成

以下から、今回の作業用にワークスペースを作成する。

https://slack.com/intl/ja-jp/help/articles/206845317-Slack-%E3%83%AF%E3%83%BC%E3%82%AF%E3%82%B9%E3%83%9A%E3%83%BC%E3%82%B9%E3%82%92%E4%BD%9C%E6%88%90%E3%81%99%E3%82%8B

## Slack bot の作成

以下から新しいアプリを作成する。

https://api.slack.com/apps/

右上の「Create New App」をクリックして、「From scratch」をクリック。
アプリの名前を入力して、使いたいワークスペースを選択する。今回用にワークスペースを作成した場合はそのワークスペースを選択する。
Create App をクリックして作成。

## 権限を付与する

アプリが作成されると以下のような画面になるので、左側のメニューから「OAuth&Permissions」をクリック。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1743460/332727f9-2ac8-4f47-8933-a2597f5beb13.png)

少し下にスクロールしたところにある「Scopes」で以下の権限を追加する。

* app_mentions:read
* chat:write

他に権限が必要な場合は、用途に合わせて以下から必要な権限を追加する。

https://api.slack.com/scopes

ページの先頭に戻り、「OAuth Tokens」をコピーして控える (Azure Functions App の中で利用する)。

## イベントのサブスクリプション設定

今回は、アプリがメンションされた時に Azure Functions App を呼び出して Dify のチャットワークフローを実行するので、そのイベントを拾うための設定をする。

左側のメニューから「Event Subscription」をクリック。「Enable Events」のトグルボタンをクリックして、「On」にする。
アコーディオンメニューに「Subscribe to bot events」があるのでクリックすると、そのイベントを設定できる。「Add Bot User Event」をクリックして、「app_mention」を選択する。

「Request URL」のテキストボックスがあるが、ここに設定した URL に対してイベント発生が発生した際にリクエストが実行される。設定する初回は、その URL が正しいかの検証が行われる。具体的には、https://api.slack.com/events/url_verification の内容のリクエストが行われ、リクエストボディ内の `"challenge"` に設定されている文字列がそのまま返ってくるか検証される。

他のイベントについて詳しくはここ↓↓↓

https://api.slack.com/apis/events-api

### チャットワークフローの作成
Dify でチャットワークフローを選択した際のデフォルトのままで作成する。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1743460/0866a63a-5b0e-4123-b142-4458b6d92106.png)

サイドバーにある、API アクセスから API KEY を発行して、控えておく (Azure Functions App の中で利用する)

### API の作成

今回は以下のような形で作成してみた。参考まで！

注意点としては以下。
* Events API はレスポンスが3秒以内に返ってくることを期待している
    * 3秒以内に返ってこない場合、リトライが3回まで行われる
    * LLM を使うとタイムアウトすることが多いので、今回はリクエストヘッダに `x-slack-retry-num` があれば無視するようにしている
* Azure Functions App ではいくつかの認証方式があるが今回は簡易的なので、認証なしにしている (`AuthLevel.ANONYMOUS` の部分)
* 各環境変数は事前に設定する
* Storage Account についても事前に作成しておく
    * 接続文字列を Azure Functions App 内で利用する

下記コードを Azure 上にデプロイして、発行される URL をテキストボックスに入力すると、検証用のリクエストが実行される。問題なければ、「Verified」のマークがつく。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1743460/799f1485-96d7-4da6-a8c0-6ef5f8b3f649.png)

<details>
<summary> Codes </summary>

```python
import json
import logging
import os

import azure.functions as func
import requests
from azure.storage.blob import BlobServiceClient

app = func.FunctionApp()

# Environment variables
DIFY_CHAT_WORKFLOW_API_KEY = os.getenv("DIFY_CHAT_WORKFLOW_API_KEY", "")
DIFY_APP_BASE_URL = os.getenv("DIFY_APP_BASE_URL", "")
SLACK_OAUTH_TOKEN = os.getenv("SLACK_OAUTH_TOKEN", "")
STORAGE_ACCOUNT_CONNECTION_STRING = os.getenv("STORAGE_ACCOUNT_CONNECTION_STRING", "")


@app.route(route="slack/events", auth_level=func.AuthLevel.ANONYMOUS)
def slack_events(req: func.HttpRequest) -> func.HttpResponse:
    # Ignore retry requests from Slack
    if is_retry_request(req):
        return func.HttpResponse(status_code=200)

    logging.info("slack_events function was triggered")

    req_body = req.get_json()
    logging.info(f"request body: {req_body}")

    # Handle Slack URL verification challenge
    if "challenge" in req_body:
        return func.HttpResponse(
            body=slack_verification(req_body),
            headers={"Content-Type": "text/plain"},
            status_code=200,
        )

    # Extract necessary information from the request body
    channel = req_body["event"]["channel"]
    thread_ts = req_body["event"].get(
        "thread_ts", req_body["event"]["ts"]
    )  # when start a conversation, thread_ts is not included and use ts instead
    user = req_body["event"]["user"]

    # Retrieve conversation ID from storage
    conversation_id = get_conversation_id(thread_ts)
    logging.info(f"conversation_id: {conversation_id}")

    # Process the Slack app mention event
    content, conversation_id = slack_app_mention(req_body, conversation_id)
    store_conversation_id(thread_ts, conversation_id)

    # Send a message to Slack
    status = send_message_to_slack(content, channel, thread_ts, user)
    msg = (
        "the message was sent successfully"
        if status == 200
        else "failed to send the message"
    )

    return func.HttpResponse(body=msg, status_code=status)


def is_retry_request(req: func.HttpRequest) -> bool:
    # Check if the request is a retry request from Slack
    if "x-slack-retry-num" in req.headers:
        logging.info(
            f"ignore the retry request x-slack-retry-num: {req.headers['x-slack-retry-num']}"
        )
        return True
    return False


def slack_verification(req_body: dict) -> str:
    # Return the challenge token for Slack URL verification
    logging.info(f"slack_verification executed")

    return req_body["challenge"]


def slack_app_mention(req_body: dict, conversation_id: str) -> str:
    # Handle Slack app mention event and call the workflow to get a response
    logging.info(f"slack_app_mention executed")

    query = req_body["event"]["blocks"][0]["elements"][0]["elements"][1]["text"]
    req_msg = {
        "inputs": {},    # 作成したチャットワークフローによって指定する
        "query": query,
        "response_mode": "blocking",
        "conversation_id": conversation_id,
        "user": "Azure Functions App",
        "files": [],
    }

    return chat_messages_run(req_msg)


def chat_messages_run(req_msg: dict):
    logging.info(f"chat_messages_run: {req_msg}")

    request_headers = {
        "Authorization": f"Bearer {DIFY_CHAT_WORKFLOW_API_KEY}",
        "Content-Type": "application/json",
    }

    response = requests.post(
        url=DIFY_APP_BASE_URL + "/chat-messages",
        headers=request_headers,
        json=req_msg,
    )
    logging.info(
        f"Response Code from Dify app: {response.status_code}, {response.json()}"
    )

    return response.json()["answer"], response.json()["conversation_id"]


def send_message_to_slack(content: str, channel: str, thread_ts: str, user: str) -> int:
    logging.info(
        f"send_message_to_slack: {content[:100]} ..., {channel}, {thread_ts}, {user}"
    )

    url = "https://slack.com/api/chat.postMessage"
    headers = {
        "Authorization": f"Bearer {SLACK_OAUTH_TOKEN}",
        "Content-Type": "application/json",
    }
    body = {
        "channel": channel,
        "text": f"<@{user}>\n{content}",
        "thread_ts": thread_ts,
        "blocks": [
            {
                "type": "section",
                "text": {
                    "type": "mrkdwn",
                    "text": f"<@{user}>\n{content}",
                },
            }
        ],
    }

    response = requests.post(url=url, headers=headers, json=body)
    logging.info(f"Response Code from Slack: {response.status_code}, {response.json()}")

    return response.status_code


def get_conversation_id(thread_ts: str) -> str:
    logging.info(f"get_conversation_id: {thread_ts}")

    blob_service_client = BlobServiceClient.from_connection_string(
        conn_str=STORAGE_ACCOUNT_CONNECTION_STRING
    )
    container_client = blob_service_client.get_container_client("func-data")

    blob_name = f"{thread_ts}.json"

    if blob_name in [blob.name for blob in container_client.list_blobs()]:
        logging.info(f"blob name: {blob_name} is found")

        blob_client = container_client.get_blob_client(blob=blob_name)
        data = blob_client.download_blob().readall()
        data = json.loads(data.decode("utf-8").replace("'", '"'))
        return data["conversation_id"]
    else:
        logging.info(f"blob name: {blob_name} is not found")
        return ""


def store_conversation_id(thread_ts: str, conversation_id: str):
    logging.info(f"store_conversation_id: {thread_ts}, {conversation_id}")

    blob_service_client = BlobServiceClient.from_connection_string(
        conn_str=STORAGE_ACCOUNT_CONNECTION_STRING
    )
    container_client = blob_service_client.get_container_client("func-data")

    blob_name = f"{thread_ts}.json"
    data = json.dumps({"conversation_id": conversation_id})

    if blob_name not in [blob.name for blob in container_client.list_blobs()]:
        logging.info(f"blob name: {blob_name} is not found, create a new blob")

        blob_client = container_client.get_blob_client(blob=blob_name)
        blob_client.upload_blob(data)
```

</details>


## 実行してみる

Slack から対象の Bot に対してメンションしてメッセージを送ると Dify で作成したチャットワークフローが呼び出され、過去の内容も踏まえて回答してくれる！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1743460/ed728908-ccf1-4367-93db-25325e2f3feb.png)

## ということで
Dify で作成したチャットワークフローを Slack Bot で呼び出せるようにしてみました。普段利用する機会の多い Slack で気軽に呼べるのはやはり便利ですね。
コード内の `"inputs": {},    # 作成したチャットワークフローによって指定する` の部分が、Slack 上からだと指定するのが難しそうなので、何らか方法を考えたい箇所です。

Dify はアップデートが早く、今回は 0.15.3 を使いましたが 1.0.X がすでにリリースされプラグイン機能が追加されたことで、ますます利便性が向上しそうです！

https://marketplace.dify.ai/


以上です。