2023/07/24

きんたくん is 何?

こんにちは!㉔です。
猛暑だったり、大雨だったり、ジメジメした日が続いていますね・・・
皆さん、くれぐれも体調お気を付けください><

さて、今回はAz One自社開発の勤怠管理システムである『きんたくん』のお話です。
ChatOpsでの勤怠管理を実現するために、Discordに勤怠管理機能のあるBotを組み込んで運用しています。きんたくんの構成やChatOpsって何?という方は前回の記事を御覧ください。この記事では主にDiscord Botの開発・保守の手法について解説します。

Discord Botの開発

チュートリアル

身も蓋もありませんが公式のチュートリアルが用意されているので、bot開発の基礎を学びたい場合は公式サイトを参照するのがいいでしょう。(個人的に某エンジニア情報共有コミュニティ等でチュートリアルを学ぶのはおすすめしません)

開発手法

公式の推奨方法

Discord Botの開発ではDiscordサーバーとの通信方法が2つあることがわかります。公式のチュートリアルではDiscordリソースに対してREST操作を行うHTTP APIを使用しており、もう一方はGateway APIを使用することでWebSocket接続をする方法です。そもそもWeb Socket自体がHTTPより複雑であること、多くのケースではGateway接続の必要は無いことから公式としてもHTTP APIを使用する方法を推奨しているようです。

しかし、後述しますが本格的な開発においては外部ライブラリを使用したほうがよいとの判断から、きんたくんではGateway APIを利用したWeb Socketでの接続でBotを稼働します。

外部ライブラリ利用したいですよね

公式のチュートリアルを実践するとわかりますがDiscord Botの開発に外部ライブラリは不要です。(チュートリアルでは実装をシンプルにするためdiscord-interactions-jsというDiscord純正ライブラリは使用しています)しかし、チュートリアルの簡易なジャンケンゲームの実装でさえ実装は複雑です。

 

// コマンドの種別を見たりタイプをチェックしたり結構やること多い
if (type === InteractionType.MESSAGE_COMPONENT) {
// custom_id set in payload when sending message component
const componentId = data.custom_id;

  if (componentId.startsWith('accept_button_')) {
    // get the associated game ID
    const gameId = componentId.replace('accept_button_', '');
    // Delete message with token in request body
    const endpoint = `webhooks/${process.env.APP_ID}/${req.body.token}/messages/${req.body.message.id}`;
    try {
      await res.send({
        type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
        data: {
          // 省略
        },
      });
    ...



ベーシックな実装方法を知った上で便利なFWやライブラリを使用することは、コードの保守やチーム開発において有利に働きます。そのためきんたくんでは外部のDiscord Bot開発FW、ライブラリを利用することにしました。

ライブラリの選定

調べた限り採用できそうなOSSライブラリは2つあります。どちらもGitHub Starが10kを超えており、今なお開発は継続しているようなのでどちらを採用しても問題ないと思います。私のメインはフロントエンドエンジニアなので慣れているdiscord.jsを採用しました。

discord.py

非同期処理にも対応したDiscord用のAPIラッパー。

discord.js

Node.jsモジュールのDiscordAPIラッパー。

discord.js辛い

モダンなフロントエンドエンジニアの多くはTypescript以外で開発できない体になってしまったと思いますが、私も例外ではありません。discord.jsもTypescriptでの開発が可能ですがこれが結構辛かったです。

discord.jsではSlash Commandをコマンド毎に個別のファイルで作成することでDiscordの公式チュートリアルのようなコードの複雑さを回避できます。作成したCommandを動的importで読み込みます。Typescriptでは動的Importでも型をサポートしますが、公式のチュートリアルではディレクトリをパースしてimportするため型チェックが効きません。(ここの処理でCommandの型が重要になることはないのでまだ問題無い)

for (const file of commandFiles) {
		const filePath = path.join(commandsPath, file);
    // そもそも require なんてここ数年書いてない...
    // この command は any 型になる
		const command = require(filePath);
		if ('data' in command && 'execute' in command) {
			commands.push(command.data.toJSON());
		} else {
			console.log(`[WARNING] The command at ${filePath} is missing a required "data" or "execute" property.`);
		}
	}

また、チュートリアル内で Command Collectionの中から実行された Command に一致するものを取得する処理がありますが、なぜかこのinteraction.clientにはcommandsがないためTypescriptではエラーになります。(これは本当に謎)

そのためTypescriptの既存型の拡張方法であるDeclaration Mergingを用いて拡張する必要があります。チュートリアルでこれだともうdiscord.js辛いとなってきます。(なった)

client.on(Events.InteractionCreate, async interaction => {
	if (!interaction.isChatInputCommand()) return;
  // ここで client は無いよとエラーになる
	const command = interaction.client.commands.get(interaction.commandName);
	if (!command) {
    ...
	}

// 解決するには独自に型を拡張する必要がある
declare module 'discord.js' {
  interface Client {
    commands: Collection<string, CommandBase>
  }
}

第三の選択肢

Sapphireをご存知でしょうか?GitHub Starはまだ500台とOSSで言えばそこそこ有名くらいのPJです。このPJは個人的にDiscord Bot開発でTypescriptを使用する場合のデファクトスタンダードと言えるFWです。FW内でdiscord.jsを使用していますが、discord.jsで辛かったTypescriptとの親和性の低さを解決しています。また、サイトでのSapphireの特徴として以下を挙げていることからもTypescriptを意識した設計になっています。

Key Features

  • Advanced plugin support
  • Supports both CommonJS and ESM
  • Completely modular and extendable
  • **Designed with first class TypeScript support in mind**
  • Includes optional utilities that you can use in any project

Sapphireでの開発

親の顔より見た公式ドキュメント

記事の最初で述べた通りSapphireのチュートリアルは公式ドキュメントの参照を強くおすすめします。特にSapphireはTypescriptの新しい構文バージョンへ早期に対応している(きんたくん開発中にTypescriptが5.0になり、コンパイラオプションの破壊的な修正がありましたがSapphireは早期に対応しました)ので最新のドキュメントを見ることをおすすめします。

この記事ではSapphireを採用して開発時に感じたメリットを紹介します。

チーム開発の容易さ

Sapphireはコマンドや権限チェック、Interaction等ネイティブ開発では煩雑な処理になりがちな要素をファイル毎に分離しできるように構成されたFWです。そのためチームでBotを開発する際もコンフリクト等チーム開発の妨げになる要素が少なく非常に使い勝手が良かったです。

コマンド

commandsというディレクトリ配下にコマンド毎のファイルを作成することで個々のコマンドの実装を分離できます。この構成自体はdiscord.jsでも同様ではありますが、discord.jsとは異なりコマンドの読み込みはFWが実施するため動的importは必要ありません。開発者はコマンドの実装だけに注力できます。そのためメンバーにコマンドの実装をアサインしても、殆どのケースでコンフリクトすることはありませんでした。

└── src
    ├── commands
    │   └── ping.ts
    └── index.ts

権限管理

コマンド実行前にコマンドの実行可否を判定するためのPreconditionsというフックが用意されています。この機能によりきんたくんを利用できるユーザー、きんたくんの管理者向けのコマンドを利用できるユーザーなどをロールや権限により制限できるようになります。

preconditionsというディレクトリ配下にフック毎にファイルを分離できるため、複数の権限チェックでも可読性や保守性を維持したまま開発が可能です。

└── src
    └── preconditions
        └── OwnerOnly.ts

Interaction

メッセージコンポーネントなどを用いたInteractionへの応答はネイティブの実装では煩雑になりがちでした。(メッセージのもつ固有の識別子などで処理を分ける必要がある)interaction-handlersというディレクトリ配下の個別のファイル毎に処理したいInteractionへの実装を分離することができます。(ただし当然ではありますがinteractionに応答するかどうかはファイル毎に個別の実装は必要です)

└── src
    └── interaction-handlers
        └── Button.ts

Logger

Sapphireはプラグインの組み込みも用意なFWになっています。今回の実装でプラグインを作成するようなことはありませんでしたが、既存プラグインであるLoggerに十分な機能がありました。そのためロガー等どうするかで検討する時間を省略できたのは良かったです。他にも多くのプラグインが提供されているようなので気になる方は色々探してみて下さい。

プラグインの作成方法もドキュメントが充実しているので、もしプラグインを作成したいと思った方は参照してみてください。

最後に

この記事でSapphireの良さが伝われば幸いです。正直PJの品質や更新頻度的にもSapphireのGitHub Starが500台なのは個人的にとても歯がゆいです。ですのでもしSapphireを触ってみていいと思った方は是非Starを贈呈してあげて下さい🙏(PJの開発がストップしたら困るという下心)

次回はきんたくんのテスト周りの話をします。当然ですがPJにテストはつきものです。きんたくんは単体テストしか実施できていませんが(Botのe2eテストってどうやるんだろう)、永続化にGoogle Sheetsなどを使用しているが故のテクニックなどを紹介できればなと思います。