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などを使用しているが故のテクニックなどを紹介できればなと思います。