Slackアプリ概論（ChatGPTと対話するボットを題材に）

## イントロダクション

Slackアプリを作るとき、毎回作り方を調べては忘却している気がする。
ChatGPTと対話するSlackボットを題材に、Slackアプリの作り方を改めて振り返る。

今回の記事では最終的に次のようなSlackアプリを作成する。
（Slack上でChatGPTとやり取りするアプリ）

![output.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287774/724656fc-0e68-059a-6af5-c3469dfb0dd9.gif)

## Slackアプリの基本

### 概要

Slackアプリは[Slack API](https://api.slack.com/lang/ja-jp)を利用すると作成できる。
Slackアプリはまず自前でWebサーバを準備することから始まる。
WebサーバがSlackからのイベントを受け取り、また逆にSlack APIを叩くことでアプリケーションを実現している。

new Slack Platformも出たが今回は従来のSlack APIを利用して進める。

:::note
**補足：new Slack Platformとは？**

Slack社の方の記事が分かりやすい。
→ [Slack 次世代プラットフォーム機能のご紹介](https://qiita.com/seratch/items/ecc16b845483c9d6f9e1)

従来のSlackアプリは自前でWebサーバを準備していたが、Slack社内のクラウド環境（Slack Cloud）にアプリをデプロイできる。
主に下記3点の特徴がある。

- Run on Slack：Slack社内のクラウド環境で動く
- Slack CLI：ターミナルで開発が完結する（ローカル開発、デプロイ...）
- Denoで書く

[Slackの次世代プラットフォームを試してみた](https://tech.travelbook.co.jp/posts/slack-deno-api/)

少し使ってみた感じエコシステムが整備されており開発が楽な印象もあったが、まだ正式版でなく、加えて有料ワークスペースでのみ利用可能である。
後ほど説明するライブラリBoltが[new Slack Platformに対応](https://api.slack.com/automation/bolt)する等日々進化しており注視しておく必要がありそう。
:::

### Bolt

Slackアプリ開発は、公式が用意しているライブラリ[Bolt](https://api.slack.com/tools/bolt)を利用するのが定番。JavaScript（TypeScript）やPython、Java向けのBoltがある。
Boltは、簡単に言ってしまえばSlackとのやり取りに特化したWebアプリケーションフレームワーク（FW）で、例を見ると分かりやすい。

```typescript
app.message(':wave:', async ({ message, say }) => {
  await say(`Hello, <@${message.user}>`);
});
```

上記を実装すると👋を含むメッセージに反応し「Hello」を返すイベントハンドラがデプロイされる。
Slackのリクエスト仕様に合わせたWebサーバを自前で作ることもできるが、Boltを使えばまるっと面倒を見てもらえるので便利。

## Slackボットの作り方

圧倒的に公式のBolt入門ガイド（日本語）が分かりやすいため参考にしながら進めるのがベスト。

[Bolt 入門ガイド (HTTP)](https://slack.dev/bolt-js/ja-jp/tutorial/getting-started-http)

記事のスクショ通りに進めば誰でも簡単にSlackアプリを作成できる。
入門ガイドについて、初見だと少々分かりづらいかも（？）な部分のみを下記に列挙する。

### ① 最初のCreate New Appどっちを選ぶ？

From scratchとFrom an app manifestがあるが特別な理由がなければ前者でOK。

### ② トークンの種類

- Slackのトークンは大別すると2種類ある
- Bot User OAuth Access Token
  - WebサーバからSlack APIにアクセスするため必要なトークン
  - このトークンが付与されたリクエストのみに`chat:write`等を許可する
- Signing Secret
  - Slack APIからWebサーバにアクセスするため必要なトークン
  - リクエストにSigning Secretが付与されてるかどうかをBoltが管理

### ③ Event Subscriptionとは

イベントが起きたときに登録したエンドポイント（Webサーバ）を叩いてくれる仕組み。

### ④ ngrokとは

- ローカル開発の際に利用するサービス。詳細は[ドキュメント](https://ngrok.com/docs#getting-started-expose)にて
- 利用するとインターネット > ngrokサーバ > localhostの経路でアクセス可能になる（プロキシみたいな役割）
- 生成したURLをもとにEvent Subscription > Request URLを設定
  - 例. `https://xxx.ngrok-free.app/slack/events`
  - ngrokを一度停止するとURLが変更されるので再設定が必要
  - （new Slack Platformではもっと簡単に開発できる）

## 実装編：ChatGPTを利用したSlackボット

### 事前準備を行う

#### Slackアプリを作成する

「Slackボットの作り方」の項で説明した流れでSlackアプリを作成する。

#### 権限を付与する

今回のデモに必要なボットの権限は次のように設定する。

`OAuth & Permissions > Scopes > Bot Token Scopes`
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287774/ff1d5832-98ad-d4c2-57a2-bf625e056b25.png)

`Event Subscriptions > Subscribe to bot events`
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287774/9524d20c-6ef9-3358-d626-f421ad718d60.png)

#### チャンネル招待する

ボットを利用するチャンネルに招待する。

```sh
/invite
```

#### オプション：アイコンを設定する

ボットのアイコンを付ける場合 `Basic Information > Display Information`

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/287774/6e2f1e36-487e-a8bb-c8cb-b300e2f49adb.png)

ライセンス：<a href="https://www.flaticon.com/free-icons/robot" title="robot icons">Robot icons created by Hilmy Abiyyu A. - Flaticon</a>

### ChatGPTを使用してSlackボットを作成する

コードはGitHubに置いた。

https://github.com/kawamou/slack-ai

メインの処理だけ説明すると、まず`app.event("app_mention")`にて、アプリ宛のメンションに反応するようイベントハンドラを登録する。

```ts:index.ts
const registerSlackHandler = () => {
  // ...
  app.event("app_mention", aiDialogueHandler);
};
```

イベントハンドラは処理を受け付けると：

- 「回答を生成中」メッセージで一時回答する
- スレッドの過去やり取りをContextの形で保持して文脈を踏まえた回答を行う
  - Slackではts（タイムスタンプ）を指定してメッセージを特定する
  - スレッドやり取りの最大過去20件をContextとして取り込む
  - この実装は[OpenAIのChat Completion APIを使って、ChatGPTライクに会話ができるSlackBotを作る](https://zenn.dev/ryo_kawamata/articles/56ea2484320def)を大いに参考にした
- 過去やり取り含めてOpenAIのクライアントに渡して結果を待つ
- 結果をもとに「回答を生成中」メッセージを更新

```ts:presentation/ai_dialogue_handler.ts
const LOADING_TEXT = `:loading: 回答を生成中（長文を生成する場合は時間がかかります）`;

export const aiDialogueHandler = async ({
  event,
  client,
  say,
}: SlackEventMiddlewareArgs<"app_mention"> &
  AllMiddlewareArgs<Record<string, unknown>>) => {
  const timestamp = event.thread_ts || event.ts;

  const res = await say({
    thread_ts: timestamp,
    text: LOADING_TEXT,
  });

  const messagesResponse = await client.conversations.replies({
    channel: event.channel,
    ts: timestamp,
  });
  const messages = messagesResponse.messages?.sort(
    (a, b) => Number(a.ts) - Number(b.ts)
  );

  const context: Context = messages!
    .slice(1)
    .slice(-20)
    .filter((m) => !m.text?.includes(LOADING_TEXT))
    .map((m) => {
      const role = m.bot_id ? "assistant" : "user";
      return { role: role, content: m.text ?? "" };
    });

  const result = await completionWithContext(context, event.text);

  if (res.ts && res.channel) {
    await client.chat.update({
      channel: res.channel,
      ts: res.ts,
      text: `${result}`,
    });
  }
};
```

過去のやり取りを踏まえて`completion`APIを叩く。愚直な実装。

```ts:infra/openai_client.ts
const CHAT_GPT_SYSTEM_PROMPT = `
You are an excellent AI assistant Slack Bot.
Please output your response message according to following format.

- bold: "*bold*"
- italic: "_italic_"
- strikethrough: "~strikethrough~"
- code: " \`code\` "
- link: "<https://slack.com|link text>"
- block: "\`\`\` code block \`\`\`"
- bulleted list: "* item1"

Be sure to include a space before and after the single quote in the sentence.
ex) word\`code\`word -> word \`code\` word

Let's begin.
`;

export const openaiClient = new OpenAIApi(configuration);

export type Context = {
  role: ChatCompletionRequestMessageRoleEnum;
  content: string;
}[];

// ref. https://zenn.dev/ryo_kawamata/articles/56ea2484320def
export const completionWithContext = async (
  context: Context,
  message: string
) => {
  const response = await openaiClient.createChatCompletion({
    model: "gpt-3.5-turbo",
    messages: [
      {
        role: ChatCompletionRequestMessageRoleEnum.System,
        content: CHAT_GPT_SYSTEM_PROMPT,
      },
      ...context,
      {
        role: ChatCompletionRequestMessageRoleEnum.User,
        content: message,
      },
    ],
  });
  return response.data.choices[0].message?.content;
};
```

## Appendix

### ハンドラのモジュラリゼーション

BoltはNode.jsのWebアプリケーションFWの[Express](https://github.com/expressjs/express)をラップしている。
そのため、インスタンスの初期化時に`customRoutes`を指定すると、[通常のWeb APIを追加することも可能](https://slack.dev/bolt-js/concepts#custom-routes)。
汎用性を考えるとWeb APIのハンドラとSlackのイベントハンドラを共通に書けると嬉しく、処理をHandlerに切り出し`index.ts`にて登録するように統一した。
若干苦しいが、今後ベストプラクティスを探っていきたい。

```ts:index.ts
const registerWebAPIHandler = (): CustomRoute[] => {
  return [
    {
      path: "/ping",
      method: "GET",
      handler: pingHandler,
    },
  ];
};

const registerSlackHandler = () => {
  app.message("hello", helloHandler);
  app.action("button_click", buttonClickHandler);
  app.event("app_mention", aiDialogueHandler);
};

export const app = new App({
  token: process.env.SLACK_BOT_TOKEN,
  signingSecret: process.env.SLACK_SIGNING_SECRET,
  port: (process.env.PORT || 3000) as number,
  customRoutes: registerWebAPIHandler(),
});

app.use(checkChannelNameMiddleware);

(async () => {
  registerSlackHandler();
  await app.start();
  console.log("⚡️ Bolt app is running!");
})();
```

※補足までにcustomRoutes以外でもWeb APIを登録する方法はある（[参考](https://zenn.dev/wok/articles/0026_slack-bolt-add-services)）

### 複数ハンドラの管理

加えて、例えば`app_mention`に登録されたHandlerが複数あった場合、このイベントには反応するがこのイベントには反応しない、といった処理をミドルウェア等に書いておく必要がある。
ただ、同時に反応して欲しい場合もユースケースとしてはあり得るため、設計の最適解はまだ見えていない。

### jsx-slack

今回は特段利用していないが（Exampleコードのみ書いた）、SlackのUI部品を使う場合jsx-slackが良さそうだと思った。
参考までに使ってみたが開発体験が非常に良い。
日本発のパッケージである点も良い。

https://github.com/yhatt/jsx-slack

## 結論

ChatGPTと対話するSlackボットを題材にSlackアプリの作り方を振り返った。
次回以降、jsx-slack等を利用したよりリッチなアプリ作りに取り組む。