インタラクティブなボットメッセージング: ボタンとタスクトリガー 🖱️🔥

Discord ボットのインタラクティブな可能性を解き放ちます。このチャプターでは、魅力的なボタンメッセージを作成し、ユーザーのインタラクションによってタスクがトリガーされる設定方法に焦点を当てます。Skeet の機能を利用して、ボットを単なる機能的な存在から、インタラクティブでエンゲージングなものへとデザインする方法を学びます。

## Firestore ユーザーモデルの設計 🛠️📊

最初の重要なステップとして、私たちはFirestoreデータベースにユーザーモデルを慎重に設計します。このモデルは、ユーザー情報の管理を効率化し、アプリケーションのパフォーマンスを最適化します。このモデルはユーザーID、名前、そしてアプリケーション固有のカスタムフィールドを含むことができます。

本書では、DiscordUser と StripeUser の2つの異なるモデルを使って、ユーザーデータを管理します。単一の User モデルでの実装が可能かもしれませんが、今回はメンバーシップのステータスを持つ二種類のユーザー、つまり一般メンバーと有料会員メンバーを別々に実装します。多数の一般ユーザーと限られた数の会員メンバーのデータを効率的に処理するため、分けて管理することでクエリのコストを削減し、スケーラビリティを向上させます。

このセクションでは、ユーザーのID、名前、アイコン、役割、レベル、HP、MP、経験値、作成日時、更新日時などを含めた総合的なユーザーモデルを構築します。ここでは、簡潔ながらも柔軟性に富んだモデルを紹介し、実践的なコーディング例を通してその構築方法を学びます。

`functions/skeet/src/lib/models/discordUserModels.ts` における DiscordUser の型定義は以下の通りです：

```typescript
import { Timestamp, FieldValue } from '@skeet-framework/firestore'

// CollectionId: DiscordUser
// DocumentId: auto
// Path: DiscordUser
export const DiscordUserCN = 'DiscordUser'
export type DiscordUser = {
  id?: string
  pubkey: string
  username: string
  email: string
  iconUrl: string | null
  roles: string[]
  lv: number
  hp: number
  mp: number
  exp: number
  isMembership: boolean
  createdAt?: Timestamp | FieldValue
  updatedAt?: Timestamp | FieldValue
}
```

これで、Firestore データベースにユーザーモデルを作成する準備が整いました。

## ３つボタンを使ってユーザーのデータを Firestore と連携 🖲️💾

次に、Discord ボットにボタン機能を実装し、ユーザーがボタンをクリックすることで Firestore に自動的に登録されるようにします。これにより、ユーザーが簡単にボットの機能を利用できるようになります。

以下の３つのボタンを作成します。

- 登録ボタン
- ステータスを見るボタン
- ログインボーナスをもらうボタン

`registerMessage.ts` を以下のように追加します。

`functions/skeet/src/lib/discord/messages/registerMessage.ts`

```typescript
import {
  ButtonStyleTypes,
  MessageComponentTypes,
} from '@skeet-framework/discord-utils'

export const registerMessage = () => {
  const body = {
    content: 'ボタンを押してね！',
    components: [
      {
        type: MessageComponentTypes.ACTION_ROW,
        components: [
          {
            type: MessageComponentTypes.BUTTON,
            style: ButtonStyleTypes.PRIMARY,
            label: '📗 登録する',
            custom_id: 'register-button',
          },
        ],
      },
      {
        type: MessageComponentTypes.ACTION_ROW,
        components: [
          {
            type: MessageComponentTypes.BUTTON,
            style: ButtonStyleTypes.PRIMARY,
            label: '📕 ステータスを見る',
            custom_id: 'view-button',
          },
        ],
      },
      {
        type: MessageComponentTypes.ACTION_ROW,
        components: [
          {
            type: MessageComponentTypes.BUTTON,
            style: ButtonStyleTypes.PRIMARY,
            label: '📘 ログインボーナスをもらう',
            custom_id: 'bonus-button',
          },
        ],
      },
    ],
  }
  return body
}
```

そして、3 つの `custom_id` に対応するアクションを `functions/skeet/src/lib/discord/actions/` に作成します。

- `registerAction.ts`
- `viewAction.ts`
- `bonusAction.ts`

## ボタンを押したら Firestore にユーザーを登録する 🖲️💾

まずは、`registerAction.ts` を以下のように追加します。

`functions/skeet/src/lib/discord/actions/registerAction.ts`

```typescript
import { DiscordUser, DiscordUserCN } from '@/models/discordUserModels'
import {
  DiscordRouterParams,
  deferResponse,
  updateResponse,
} from '@skeet-framework/discord-utils'
import { add } from '@skeet-framework/firestore'
import { Response } from 'firebase-functions/v1'
import { inspect } from 'util'

export const registerAction = async (
  res: Response,
  db: FirebaseFirestore.Firestore,
  discordToken: string,
  body: DiscordRouterParams
) => {
  console.log('helloAction')
  console.log(inspect(body))
  await deferResponse(discordToken, body.id, body.token)

  const userParams: DiscordUser = {
    pubkey: '',
    username: body.member.user.username,
    email: '',
    iconUrl: body.member.user.avatar,
    roles: body.member.roles,
    lv: 1,
    hp: 100,
    mp: 30,
    exp: 0,
    isMembership: false,
  }

  const user = await add<DiscordUser>(
    db,
    DiscordUserCN,
    userParams,
    body.member.user.id
  )
  if (!user) {
    await updateResponse(discordToken, body.application_id, body.token, {
      content: `⚠️ Failed to create user.Please try again in a few seconds.`,
      flags: 64,
    })
    return false
  }

  await updateResponse(discordToken, body.application_id, body.token, {
    content: `Hello, ${userParams.username} created!\nDocumentId:${user.id}`,
    flags: 64,
  })
  return true
}
```

## ボタンを押したら Firestore のユーザーを表示する 🖲️👀

次に、`viewAction.ts` を以下のように追加します。

`functions/skeet/src/lib/discord/actions/viewAction.ts`

```typescript
import { DiscordUser, DiscordUserCN } from '@/models/discordUserModels'
import {
  DiscordRouterParams,
  deferResponse,
  updateResponse,
} from '@skeet-framework/discord-utils'
import { add, get } from '@skeet-framework/firestore'
import { Response } from 'firebase-functions/v1'

export const viewAction = async (
  res: Response,
  db: FirebaseFirestore.Firestore,
  discordToken: string,
  body: DiscordRouterParams
) => {
  console.log('viewAction')
  await deferResponse(discordToken, body.id, body.token)

  const user = await get<DiscordUser>(db, DiscordUserCN, body.member.user.id)
  if (!user) {
    await updateResponse(discordToken, body.application_id, body.token, {
      content: `⚠️ Failed to get user.Please try again in a few seconds.`,
      flags: 64,
    })
    return false
  }

  // Discord Embed Message
  const emdbed = {
    title: 'あなたのステータス🛡️',
    description: `名前: ${user.username}\nLv:${user.lv}\nHP:${user.hp}\nMP:${user.mp}\nEXP:${user.exp}`,
    color: 0x0099ff,
    timestamp: new Date(),
    footer: {
      text: 'powered by Skeet Framework',
      icon_url:
        'https://storage.googleapis.com/skeet-assets/imgs/logo/SkeetLogoSquare.png',
    },
  }

  await updateResponse(discordToken, body.application_id, body.token, {
    embeds: [emdbed],
    flags: 64,
  })
  return true
}
```

レスポンスに Discord Embed Message を追加することで、ユーザーにより魅力的なメッセージを送信することができます。

メッセージの詳細については、以下のドキュメントを参照してください。

https://discord.com/developers/docs/resources/channel#embed-object

## ボタンを押したら Firestore のユーザーを更新する 🖲️🔄

次に、`bonusAction.ts` を以下のように追加します。

`functions/skeet/src/lib/discord/actions/bonusAction.ts`

```typescript
import { DiscordUser, DiscordUserCN } from '@/models/discordUserModels'
import {
  DiscordRouterParams,
  deferResponse,
  updateResponse,
} from '@skeet-framework/discord-utils'
import { get, update } from '@skeet-framework/firestore'
import { Response } from 'firebase-functions/v1'

export const bonusAction = async (
  res: Response,
  db: FirebaseFirestore.Firestore,
  discordToken: string,
  body: DiscordRouterParams
) => {
  console.log('bonusAction')
  await deferResponse(discordToken, body.id, body.token)

  const user = await get<DiscordUser>(db, DiscordUserCN, body.member.user.id)
  if (!user) {
    await updateResponse(discordToken, body.application_id, body.token, {
      content: `⚠️ Failed to get user.Please try again in a few seconds.`,
      flags: 64,
    })
    return false
  }

  const bonus = Math.floor(Math.random() * 100)
  const newExp = user.exp + bonus
  let lv = user.lv
  const expToNextLevel = 100 * lv

  if (newExp >= expToNextLevel) {
    lv++
  }

  await update<DiscordUser>(db, DiscordUserCN, body.member.user.id, {
    exp: newExp,
    lv,
  })

  let description = `あなたは${bonus}の EXP を獲得しました！`
  if (lv > user.lv) {
    description += `\nレベルが${lv}に上がりました！`
  }
  // Discord Embed Message
  const emdbed = {
    description,
    color: 0x0099ff,
  }

  await updateResponse(discordToken, body.application_id, body.token, {
    embeds: [emdbed],
    flags: 64,
  })
  return true
}
```

## 新しいボタンメッセージの送信 🖲️🔥

`root` ディレクトリに `registerMessage` を作成し、以下のように追加します。

```typescript
import { onRequest } from 'firebase-functions/v2/https'
import { publicHttpOption } from '@/routings/options'
import { TypedRequestBody } from '@/types/http'
import { RootParams } from '@/types/http/rootParams'
import { messageChannel } from '@skeet-framework/discord-utils'
import { defineSecret } from 'firebase-functions/params'
import { registerMessage } from '@/lib/discord/messages/registerMessage'

const DISCORD_TOKEN = defineSecret('DISCORD_TOKEN')
export const root = onRequest(
  { ...publicHttpOption, secrets: [DISCORD_TOKEN] },
  async (req: TypedRequestBody<RootParams>, res) => {
    try {
      // Define your logic here
      const message = registerMessage()
      const channelId = 'your-channel-id'
      await messageChannel(DISCORD_TOKEN.value(), channelId, message)
      res.json({ status: 'success' })
    } catch (error) {
      res.status(500).json({ status: 'error', message: String(error) })
    }
  }
)
```

`skeet s` コマンドでローカルサーバーを起動し、

テスト用の `root` エンドポイント、

`http://127.0.0.1:5001/your-project-id/asia-northeast1/root` 

にアクセスすると、ボタンメッセージが表示されます。

![](https://storage.googleapis.com/zenn-user-upload/0ff83a273934-20231115.png)


## DiscordRouter にボタンアクションを追加する 🖲️🔥

`functions/skeet/src/routings/discordRouter.ts` にボタンアクションを追加します。

if 文の中に、ボタンの `custom_id` に対応するアクションを追加します。

```typescript
// Import new actions
import { viewAction } from '@/lib/discord/actions/viewAction'
import { registerAction } from '@/lib/discord/actions/registerAction'
import { bonusAction } from '@/lib/discord/actions/bonusAction'

~中略~
...
} else {
  // Button action
  const { custom_id } = req.body.data as ActionData
  if (custom_id.match(/^register-button$/)) {
    await registerAction(res, db, DISCORD_TOKEN.value(), req.body)
  }
  if (custom_id.match(/^view-button$/)) {
    await viewAction(res, db, DISCORD_TOKEN.value(), req.body)
  }
  if (custom_id.match(/^bonus-button$/)) {
    await bonusAction(res, db, DISCORD_TOKEN.value(), req.body)
  }
}
...
```

これで、ボタンを押すと Firestore にユーザーが登録され、ユーザーのステータスを見ることができ、ログインボーナスをもらうことができるようになりました。

## デプロイして Discord ボットを更新する 🖲️🔥

それでは、デプロイして Discord ボットを更新しましょう。

```bash
skeet deploy --function skeet:discordRouter
```

## ボタンアクションを実行する 🖲️🔥

Discord ボットに追加したボタンを押してみましょう。

- 登録ボタン

![](https://storage.googleapis.com/zenn-user-upload/d78b702b9fbb-20231115.png)


- ステータスを見るボタン

![](https://storage.googleapis.com/zenn-user-upload/33037b3e9614-20231115.png)

- ログインボーナスをもらうボタン

![](https://storage.googleapis.com/zenn-user-upload/1b939295f9d3-20231115.png)

無事に Firestore とユーザーデータが連携されました🎉

続いて、何度かログインボーナスをもらうと

![](https://storage.googleapis.com/zenn-user-upload/5635cb28a32f-20231115.png)

レベルがあがりました🎉

![](https://storage.googleapis.com/zenn-user-upload/75c346642a27-20231115.png)

このように Discordのインタラクションと Firestoreのデータを連携させることができました。