timesを全部共有してくれるdiscordボットを更新した
~この記事はかろ噴水・ゆるゆる Advent Calendar 2023 に参加しています~
はじめに
こんにちは。calloc134 です。
以前、このような記事を執筆しました。
今回は、これを改善したボットを作成したお話をしたいと思います。
前回のボットの問題点
このときに作成したボットなのですが、discord.js を利用しない discord interaction 形式であるため、いくつかの問題がありました。
具体的には、
- 投稿を指示しても数秒後に投稿される
- メッセージの時系列がおかしくなる
- メッセージが投稿されない
などの問題が発生していました。
要するに、動作が不安定であるということです。
そこで、discord.js を利用したコードでボットを作り直すことを決意しました。
要件定義
今回は、この欠点を克服可能な要件を定義しました。
- discord.js を利用する
- そのためには常時起動のサービスを選択する必要がある
- データベースを利用する
- そのためにはデータベースの組み込み可能なサービスを選択する必要がある
サービス選択
以前利用していた cyclic.sh というサービスではサーバレスでオンデマンドな動作を行うため、常時起動の求められる discord.js を利用することができません。
また、データベースの組み込みができるサービスで、かつ無料のサービスを利用する必要があるため、選択肢は更に限られます。
しかし、そのような厳しい要件をクリアする、Zeabur というサービスを見つけました。
このサービスは Heroku のようにコードをデプロイできるサービスであり、また常時起動が可能であるため、discord.js を利用することができます。
以下の画面のように、プロジェクトを作成してその内部にサービスを作成することができます。
デプロイできるサービスに、Github からのデプロイと、Prebuilt
と呼ばれるものが存在します。
このPrebuilt
というものはあらかじめ構築済みのプリセットのようなものですが、その中には MySQL や MongoDB などのデータベース、さらにはあらゆる oss が用意されています。
個人的にはこのシステムがすごく好きです。
その他、環境変数のバインドや、ビルドコマンドとスタートコマンドの指定、ドメインの設定なども可能です。
Zeabur ですが、今回のようなサービスを利用するには、有料プランに加入する必要があります。
(一応、無料プランでも 7 日間は自動で運用でき、7 日を過ぎても手動でログインして延長することができます。)
しかし、Zeabur のドキュメントの日本語翻訳のコントリビュートをいくつか行ったことで、運営の方から無料プランを提供していただきました。
この場を借りて、感謝を申し上げます。
これからもどんどんドキュメントコントリビュートがんばるぞ~
バックエンドの設計
今回は Typescript
で discord.js
を利用し、スラッシュコマンドの実装は fastify
と discord-interactions
を利用しています。
また、データベースは PostgreSQL
を利用しました。
データベースの ORM として Drizzle ORM
を利用しています。自分はいつも Prisma
を利用しているのですが、たまには別の選択肢を試してみたくなり、これを利用しました。
更に、データベースに毎回アクセスするのは非効率なため、キャッシュとして node-cache-manager
を利用しました。
これによって、データベースへのアクセスを減らすことができます。
node-cache
のようなライブラリの利用も検討しましたが、node-cache-manager
の方が機能が豊富であり、かつ esm に対応していたため、こちらを利用しました。
node-cache-manager
は、ストアエンジンを redis に切り替えるだけで、メモリキャッシュから redis キャッシュに容易に切り替え可能な点も評価しました。
ビルドには SWC
を利用しました。また、今回はビルド後の環境で Native ESM での実行をする試みを行いました。そのときに拡張子問題に遭遇したので、その解決策としてts-add-js-extension
というユーティリティを利用しました。
実装
このように実装しました。
データベーススキーマは以下のようになります。
データベーススキーマ
const channel_list = pgTable("channel_list", {
ulid: text("ulid").primaryKey().notNull(), // ULID
channel_id: text("channel_id").notNull(), // チャンネルID
type: integer("type").notNull(), // チャンネルのタイプ
// 0: 送信元チャンネル
// 1: 転送先チャンネル
});
それほど複雑ではないです。
今回、データベースの情報とキャッシュはクロージャに保持させるようにしました。
クロージャに保持させるようにしたコード
const dbClosure = async (db_url: string) => {
// ポスグレに接続してクライアントを作成
const db = drizzle(postgres(db_url));
// キャッシュの設定
const memory_cache = await caching("memory", {
max: 100,
ttl: 60 * 1000,
});
(...)
return {
getSourceChannelList,
getTargetChannelList,
addSourceChannelList,
addTargetChannelList,
removeSourceChannelList,
removeTargetChannelList,
};
};
チャンネル一覧を読み取るとき、キャッシュが存在する場合はキャッシュを返し、存在しない場合はデータベースから読み取ります。
ここで、送信元チャンネルにおいては要素の存在を判定するため、Set としてキャッシュを保持します。
これは、Array.includes
よりもSet.has
の方が高速であるという情報に基づいています。
Set.has
については該当ユーザが任意のチャンネルに投稿するたびに呼び出されるため、高速化が求められます。
送信元チャンネルのデータを返却する関数
// 送信元チャンネルのデータを返却する関数
const getSourceChannelList = async () => {
// もしキャッシュにデータがあればそれを返却する
const cache = (await memory_cache.get("source_channel_list")) as
| Set<string>
| undefined;
if (cache) {
console.debug("cache hit", cache);
return cache;
}
// データベースから送信元チャンネルのデータを取得する
const channelList = await db
.select({
channel_id: channel_list.channel_id,
})
.from(channel_list)
.where(
eq(channel_list.type, 0) // 送信元チャンネル
);
// 結果を求める
const result = new Set(channelList.map((channel) => channel.channel_id));
// キャッシュにデータを追加する
await memory_cache.set("source_channel_list", result);
console.debug("cache miss", result);
// 結果を返却する
return result;
};
また、転送先チャンネルにおいては、要素の存在を判定する必要がないため、Array としてキャッシュを保持します。
転送先チャンネルのデータを返却する関数
// 転送先チャンネルのデータを返却する関数
const getTargetChannelList = async () => {
// もしキャッシュにデータがあればそれを返却する
const cache = (await memory_cache.get("target_channel_list")) as
| Array<string>
| undefined;
if (cache) {
console.debug("cache hit", cache);
return cache;
}
// データベースから転送先チャンネルのデータを取得する
const channelList = await db
.select({
channel_id: channel_list.channel_id,
})
.from(channel_list)
.where(
eq(channel_list.type, 1) // 転送先チャンネル
);
// 結果を求める
const result = channelList.map((channel) => channel.channel_id);
// キャッシュにデータを追加する
await memory_cache.set("target_channel_list", result);
console.debug("cache miss", result);
// 結果を返却する
return result;
};
データが更新された時は、キャッシュを削除します。
送信元チャンネルのデータを追加する関数(例)
// 送信元チャンネルのデータを追加する関数
const addSourceChannelList = async (channel_id: string) => {
// データベースに送信元チャンネルのデータを追加する
await db.insert(channel_list).values({
ulid: ulid(),
channel_id: channel_id, // チャンネルID
type: 0, // 送信元チャンネル
});
// キャッシュを削除する
await memory_cache.del("source_channel_list");
};
discord.js とは別に、fastify と discord-interactions を利用してスラッシュコマンドを実装しています。
動作例
今回のボットには、送信元チャンネルと転送先チャンネルの二種類があります。
送信元チャンネル
このチャンネルに投稿されたメッセージを、転送先チャンネルに転送します。
転送先チャンネル
転送されたメッセージを、このチャンネルに投稿します。
チャンネル ID の登録
/add_source_channel_id
コマンドと/add_target_channel_id
コマンドを利用して、チャンネル ID を登録します。
チャンネル ID の確認
/show_source_channel_id
コマンドと/show_target_channel_id
コマンドを利用して、チャンネル ID を確認します。
チャンネルの削除
/remove_source_channel_id
コマンドと/remove_target_channel_id
コマンドを利用して、チャンネル ID を削除します。
投稿の作成
先ほど指定した送信元チャンネルに投稿を行います。
すると、転送先チャンネルに投稿が行われます。
大成功!
おわりに
一日溶かしましたが、なんとか完成させることができました。嬉しい!
Zeabur の運営には本当に感謝です!
運営の方が「日本の皆さんにも是非使ってほしい」とおっしゃっていたので、是非使ってみてください!
おまけ
以下は Zeabur の招待コードです。
ここから登録するとかろっくの助けになります、なにとぞ~~~
宣伝
この記事はかろ噴水・ゆるゆる Advent Calendar 2023 に参加しています!
かろ噴水とは、自分が運営しているコミュニティです。
招待制ですが、誰でも参加できるようになっているので、興味があったら twitter や discord の@calloc134
までご連絡ください!
今回の times ボットもここで稼働しています。
Discussion