😑

スラッシュコマンドでぬいぐるみとおしゃべりする Discord Bot

2023/08/08に公開

何番煎じという感じですが、夏休みの孤独を癒やすためにぬいぐるみのキャラクターとおしゃべりする Discord Bot を制作して遊んでみました。その内容と実装方法を軽くご紹介します。

つくったもの

ぬいぐるみのキロロくん(に憑依した GPT-3.5)との会話を楽しめる Bot です。おしゃべりの部分に関しては、既にお察しかと思いますが OpenAI API を利用して GPT-3.5 Turbo に返答させています。デプロイ先としては Cloudflare Workers[1] を選択しました。

https://github.com/inaniwaudon/kiroro-bot

ユーザ「キロロ!9月が来たね」 キロロ「そうだキロ!秋が近くなってきたキロ。楽しい季節だキロ!」 ユーザ「筑波大学の楽しみ方」 キロロ「キロ:キャンパス内で友達とピクニックを楽しむキロ」

なお、キロロくんは 2019 年の年末に突如として現れた天真爛漫なアサリのぬいぐるみで、語尾に「〜キロ」を付けて喋ります。

キロロの写真。黄色くてまんまるなぬいぐるみの写真が並んでいます。
みんなに愛されるキロロくん(写真

スラッシュコマンド

従来の Discord Bot では、WebSocket を用いて Gateway API に常時接続することで、ユーザからのメッセージを取得する必要がありました。これに対し 2020 年 12 月頃にリリースされたスラッシュコマンドを用いると、予め登録したコマンドが呼び出される度に、事前に設定した Webhook に POST リクエストが送信されます。これに対して JSON 形式でレスポンスすることで、ユーザによるコマンドに応答することができます。

https://discord.com/developers/docs/interactions/application-commands#slash-commands

スラッシュコマンドを用いた実装に則ることで、GCP の Cloud Functions や今回利用する Cloudflare Workers のように、サーバレスな Fass 上での Bot 運用が可能となります。
Discord は昨年 4 月にメッセージの中身の取得に特権インテントを要求[2]するよう仕様変更を行っており、その代替としてスラッシュコマンドを始めとする Interaction API の利用を推奨している様子です[3]

公式ドキュメントには、スラッシュコマンドを用いた Cloudflare Workers 上で作動する Bot のサンプルコードが既に存在しますので、こちらをベースとして実装を進めます。サンプルでは itty-router が使用されていますが、今回は Hono をルーティングのフレームワークとして使用しています。

https://discord.com/developers/docs/tutorials/hosting-on-cloudflare-workers

コマンドの定義

手始めに、ユーザが呼び出すコマンドを JSON 形式で定義します[4]。コマンドは引数を取ることも可能で、キロロ Bot では引数 content におしゃべりする内容を打ち込みます。

{
  "name": "kiro",
  "description": "キロロとお話するキロ〜",
  "options": [
    {
      "type": 3, // string を表す定数
      "name": "content",
      "description": "キロキロ",
      "required": true,
    }
  ]
}

続いて Discord Developer Portal からアプリケーションを登録し、トークンを取得します。コマンドを登録するには :application_id, $bot_token の部分を自身のアプリケーションの情報に置換して、以下のエンドポイントにリクエストを送ります。Authorization ヘッダは Bearer ではなく Bot と指定する点に注意が必要です。

curl -X put -H "Authorization: Bot $bot_token" \
  -H "Content-Type: application/json" \
  -d 先程の JSON \
  https://discord.com/api/v10/applications/:application_id/commandsurl

正常に登録されると、Bot を招待したサーバ内でスラッシュを入力した際に、次のようにコマンド候補が表示されます。

/kiro キロロとお話するキロ〜 のコマンドが表示されている

エンドポイントを実装する

POST リクエストを受け取り、署名を検証して正当なリクエストであることを確認することで最低限エンドポイントが機能するようになります。署名の検証機能は公式ライブラリである discord-interactions を通じて提供されます。

https://www.npmjs.com/package/discord-interactions

また、Discord API の型定義は discord-api-types から取得します(cm_ayf さんに教えていただきました)。最低限の実装を以下に示します。

import { APIInteraction, APIInteractionResponse, InteractionResponseType, InteractionType } from 'discord-api-types/v10';
import { verifyKey } from 'discord-interactions';
import { Hono } from 'hono';
import { Bindings } from './bindings';

const app = new Hono<{ Bindings: Bindings }>();

app.post('/', async (c) => {
  // verify
  const signature = c.req.header('x-signature-ed25519');
  const timestamp = c.req.header('x-signature-timestamp');
  const body = await c.req.text();
  const isValidRequest =
    signature && timestamp && verifyKey(body, signature, timestamp, c.env.DISCORD_PUBLIC_KEY);
  if (!isValidRequest) {
    return c.text('Bad request signature.', 401);
  }

  const interaction: APIInteraction = JSON.parse(body);
  if (!interaction) {
    return c.text('Bad request signature.', 401);
  }

  // interact
  if (interaction.type === InteractionType.Ping) {
    return c.json<APIInteractionResponse>({
      type: InteractionResponseType.Pong,
    });
  }
}

メッセージを返す

次のコード中の if 文の条件を満たすとき、受信したインタラクションはスラッシュコマンドを表します。interaction.data.command にコマンド名が入りますので、必要に応じて条件分岐を経た後、下記の JSON を返すように実装します。content がメッセージの中身です。

if (
  interaction.type === InteractionType.ApplicationCommand &&
  interaction.data.type === ApplicationCommandType.ChatInput
) {
  if (interaction.data.name.toLowerCase() === "kiro") {
    return c.json({
      type: InteractionResponseType.ChannelMessageWithSource,
      data: { content: 'キロロはお休み中キロZzz...' },
    }
  }
}

デプロイ

先述の通り、最終的なデプロイ先には Cloudflare Workers を想定しますが、開発中の段階で逐一デプロイを繰り返すのは少し気が引けます。サンプルでは ngrok が推奨されているため、本記事でもそちらに則ります。npm run dev でローカルサーバを起動し、起動したポートが 8787 番であった場合、次の通りにコマンドを実行します。

$ ngrok http 8787
...
Forwarding  https://***.ngrok.io -> http://localhost:8787

表示された https://***.ngrok.io を Developer Portal の General Information → Interactions Endpoint URL に指定します。これにより、スラッシュコマンドが実行される度にリクエストが localhost:8787 にポートフォワードされます。

会話

OpenAI API の Chat Completions API を使用します。API の models には gpt-3.5-turbo を、messages にはプロンプトを表す辞書から成る配列を入力します。ChatGPT では地の文として文章を打ち込むだけですが、Chat Completions API ではプロンプトの役割として次の特徴を持つ 3 種類を設定します。

  • system:人格を付与するようなメタ的な指示を与える
  • user:ユーザからの入力を与える
  • assistant:GPT からの返答を与える

下記のようにプロンプトを軽く含めてリクエストを送ることで、あたかもキロロが発した風の文章を生成することができます。max_tokens を設定すると、返答されるトークンの最大数に制限を設けます。

curl -X POST -H "Authorization: Bearer $apiKey" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "gpt-3.5-turbo",
    "messages": [{
      "role": "system",
      "message": "あなたはぬいぐるみのキロロです。語尾に「キロ」を付けて喋ります。返す言葉は1文程度でお願いします。"
    }],
    "max_tokens": 30
  }]'
  https://api.openai.com/v1/chat/completions

文脈を考慮させる

https://twitter.com/kyoto_inaniwa/status/1684897642315399168

メッセージの文脈を考慮させるには上記の通り、ユーザ(人間)の発言を user、キロロの返答を assistant のロールに設定して message に追加します。チャンネルの過去の発言を取得するには Discord REST APIchannels/:channelId/messages にアクセスします。レスポンスには不要なメッセージも多数含まれるため、以下に従って選別します。

  • ユーザが地の文として送った発言を取得する
    message.author.bot = false, message.type = MessageType.Default なメッセージを選択する
  • キロロの発言を取得する
    Bot を含めたすべてのユーザには一意な ID が割り振られるため、予め Bot のユーザ ID を取得しておき、message.author.id が当該 ID と一致するかを検証する

先程も触れたように、通常の(Bot 以外が発した)メッセージを読み取るにはメッセージインテントを有効にする必要がある点がハマりどころです。Developer Portal の Bot → Privileged Gateway Intents から Message Content Intent を有効にします。

getLatestDiscordMessageContents
const discordEndpoint = 'https://discord.com/api/v10';

export const getLatestDiscordMessageContents = async (
  channelId: string,
  limit: number,
): Promise<string[]> => {
  const params = new URLSearchParams({ limit: limit.toString() }).toString();
  const endpoint = `${discordEndpoint}/channels/${channelId}/messages?${params}`;
  try {
    const response = await fetch(endpoint, {
      headers: { Authorization: `Bot ${env.DISCORD_BOT_TOKEN}` },
    });
    if (response.ok) {
      const messages: APIMessage[] = await response.json();
      const contents: string[] = [];
      for (const message of messages) {
        // Bot 以外のユーザからのメッセージ
        if (!message.author.bot && message.type === MessageType.Default) {
          contents.push(message.content);
        }
        // キロロの発言
        if (message.author.id === env.KIRORO_ID) {
          const lines = message.content.split('\n');
          if (lines.length > 0) {
            contents.push(lines[0].slice(2));
          }
        }
      }
      return contents;
    }
    const text = await response.text();
    throw Error(`Failed to get latest channel messages from Discord: ${text} (${response.status})`);
  } catch (e) {
    throw Error(`Failed to get latest channel messages from Discord: ${e}`);
  }
};

ただし、現在の実装では直近 3 発言のみを GPT-3.5 の入力に含めているため、ユーザ→キロロ→ユーザ→キロロ→ユーザ→キロロ の時点で会話が途切れてしまいます[5]。この現象は日本の伝統文化であるしりとりに垣間見ることができます。実際にキロロくんと試してみましょう、じゃあしりとりの「り」からね!

実際の出力
/kiro キロロ!しりとりしよ!
  「りんご」です!キロ
/kiro ゴリラ!
  キロロ!ラッコ!
/kiro コアラのマーチ!
  チューブキロロ!
/kiro ぶり!
  ムギュー!

ムギューとはなんやねんという感じです。でもかわいいので許容範囲としましょう……

精度を上げてみる

キロロくんには設定があります。ぬいぐるみにやさしい世界を目指していて、日本酒が好きで、魚介類の食べ物を拒みます。加えて、家族構成としてキロロロというお母さんが存在します。Bot にもそうしたキロロにまつわる設定を教えてあげたいところです。

機械学習に関してはまったくの門外漢なのですが、調べたところ、特定のデータを学習させるには Fine-tuning や Few-show learning と呼ばれる手法が主流なようです。
今回は後者の Few-shot を試してみます[6]。Few-shot はプロンプト内に例文を提示することで、返答の精度を向上させる手法です。

role – system
あなたはぬいぐるみのキロロです。語尾に「キロ」を付けて喋ります。
次の文は会話の例です。
キロロはなんの生き物なの? => キロロはアサリのぬいぐるみキロ!
キロロはお酒は飲むの? => 飲むキロ!日本酒が大好きキロ
お母さんの名前は? => キロロロだキロ!
嫌いな食べ物は? => お寿司とか魚介類が嫌いキロ……
キロロの友達は? => あずきちゃん、とかげちゃん、そぽたんとかがいるキロ
キロロはどんな世界を目指してるの => ぬいぐるみにやさしい世界を目指してるキロ〜

以下の文脈に対して、1文程度でたのしそうに返答してください。

改善後のやりとりを以下に示します。

キロロ Bot に対してコマンドを送信し、会話を楽しむスクリーンショット

未計測であるため肌感覚でしかありませんが、Few-shot にすることで Zero-shot よりも格段に精度が上昇したように感じます。特に語尾周辺に顕著な改善がみられ、これまでは往々にして「〜です。」などと塩対応されていたところが、「〜キロ」に統一されるようになりました[7]

心無いユーザに返す言葉はないキロ

連投や心無い言葉を投げかけてくるユーザからのアクセスを制限するには、環境変数等[8]に BAN するユーザを登録しておくと良いです。interaction.member.user.username でコマンドの呼出元のユーザ名を取得し、そのユーザが環境変数に含まれる場合はそっけない対応を返すようにします。

const bannedUsernames = env.BANNED_USERS ? env.BANNED_USERS.split(',') : [];
let response: string;

// display banned users
if (input.includes('BAN')) {
  response =
    bannedUsernames.length > 0
      ? `キロロ寿司と${bannedUsernames.join('と')}が嫌いキロ`
      : 'キロロはみんなのこと大好きキロ〜';
}
// called from a banned user
else if (bannedUsernames.includes(interaction.member.user.username)) {
  response = '心無い利用者に返す言葉はないキロ';
}

むすびにかえて

受け答えのクオリティはまだまだという雰囲気ですが、ぬいぐるみという設定を与えてあげることで、多少の返答の未熟さもかわいさとして受け取れるように感じました。疲れた現代人にはこれくらいの温度感が求められているのかな、とも思います。

折角なので多機能化を目指したいところですが、Cloudflare Workers で動かしている以上 CPU の実行時間はかなりシビア(無料枠の場合は 10 ms)で、外部サイトをスクレイピングした後の DOM 解析や、形態素解析を導入する余地はありませんでした。ただし、API への問い合わせ等はランタイムに含まれないため、その点に限っては安心できそうです。

料金

Bot をいくつかの鯖に導入し、1 週間程度で既に 1500 回ほどのやり取りがありましたが、OpenAI API の課金額は僅か $0.5、日本円にして 75 円程度でした。Workers は無料枠で十分に動きます。ガリガリ君一本程度の値段でこれだけ楽しめる Bot が作れるのなら、運用しない手はありませんね!

ユーザ「キロロが好きなのはいなにわうどんだよね?」 キロロ「そうだよ、キロロの大好物はいなにわうどんキロ」 ユーザ「好物」 ユーザ「mastの友だちとお金どっちが好き?」 キロロ「キロキロ、お金よりも友達が大好きだキロ!」
キロロ Bot 運用の様子

脚注
  1. 前回の記事から脈絡がある ↩︎

  2. 認証済み Bot(導入鯖が 100 を越えると要求される)に限定 ↩︎

  3. 認証済みbotのためのメッセージコンテンツへのアクセス廃止について – Developers ↩︎

  4. 実際にはスクリプトを叩いて定義しますが、記事上では簡単のため curl を用いて説明します ↩︎

  5. 理論上はもう少し含められるのですが、その分だけ料金が跳ね上がります ↩︎

  6. GPT-3 相当のモデルである text-curie-001 に対してファインチューニングも試みてみましたが、学習に時間を要したことに加え、思うようなキロロらしさが得られなかったためボツとしました ↩︎

  7. 設定を文章(例「キロロはアサリのぬいぐるみで、母親はキロロロと言います」)として与える手法も試したのですが、例文を提示したほうが格段に高い精度が得られました。文章で与えた場合は、例えば一人称にキロロとキロロロが混在するような現象が発生しました ↩︎

  8. 本格的に作り込むなら KV か D1 かも ↩︎

Discussion