necordとbullmqで、ジョブキューCLI併用Discord botの構築
Discord.jsによるbotの構築は、ある程度のベストプラクティスがGuideで解説されているものの、ディレクトリ構造や開発環境の構築は開発者の選択に委ねられています。
この記事では、necordを活用し、NestJSのモジュールとしてbotを起動します。また、ジョブキューを挟むことで、非同期処理を実現し、イベント処理の肥大化を防ぎます。
※necordに関する日本語の情報は、おそらく当記事が初出です。ここでは全てをカバーできていません。あくまで簡単なイベント反応の例のみ掲載します。
サンプルリポジトリ
サンプルではヘルスチェックやE2Eテストの定義も含んでいますが、記事では割愛します。
この記事が想定しているbotと開発環境
- Node.js@22
- pnpm@10
- 内輪のプライベートbot
- 既にRedisを使っている
今回作るbotの機能
ユーザー追加通知
デプロイ(前)通知
GitHub Actionsでコマンドを発火することで、botの更新を通知します。
今回作るbotのアーキテクチャ
「デーモンでbotを動かしつつ、CLIコマンドでも操作できる」という要件を設定します。
ただ、AppModuleにDiscord.jsを組み込むと、CLIコマンドを叩くたびにbotが二重起動してしまうます。これを防ぐため、 BotModule
と CliModule
でエントリーポイントを分けます。
- BotModuleのみがDiscordのトークンを知っていて、botとしてデーモン化できます。
- BotModuleはキューをポーリングしており、ジョブを消費します。
- DiscordChannelServiceを使って通知を投稿できます。
- 一方のCliModuleはキューの情報しか持っていません。
- CLIコマンドやDiscordイベントが、キューに通知ジョブを追加します。
この記事で行う通知処理は、必ずしもキューを経由する必要性はありませんが、外部のAIサービスとか絡んでくると真価を発揮するでしょう。
ファイル構成 (一部抜粋)
apps/bot/
├── src
│ ├── cli.ts: CLIのエントリーポイント
│ ├── main.ts: コンテナのエントリポイント (Node.jsのデーモンとして起動)
│ ├── domain
│ │ ├── interfaces
│ │ │ └── jobs
│ │ │ └── <dto-name>.dto.ts: DTO定義
│ │ └── use-cases
│ │ ├── base-use-case.interface.ts: ベースユースケースインターフェース定義
│ │ └── <category>
│ │ ├── <use-case-name>.usecase.ts: ユースケース定義
│ │ └── <category>-use-cases.module.ts: ユースケースモジュール定義
│ ├── frameworks
│ │ ├── discord-bot
│ │ │ ├── discord-bot.module.ts: Discord botモジュール定義
│ │ │ └── discord-bot.service.ts: Discord botの基本的なイベントリスナー定義
│ │ ├── discord-channel
│ │ │ ├── discord-channel.module.ts: Discordチャンネルモジュール定義
│ │ │ └── discord-channel.service.ts: Discordチャンネルサービス定義
│ │ ├── nest
│ │ │ └── constants.ts: 依存注入用のシンボル定義
│ │ └── config.ts: コンフィグの定義
│ ├── gateways
│ │ ├── commands
│ │ │ ├── <category>
│ │ │ │ └── <command-name>.command.ts: CLIコマンド定義
│ │ │ └── commands.module.ts: CLIコマンドモジュール定義
│ │ ├── discord-events
│ │ │ ├── <event-name>.command.ts: Discordイベントリスナー定義
│ │ │ └── discord-events.module.ts: Discordイベントモジュール定義
│ │ └── queues
│ │ ├── <queue-name>.consumer.ts: キューコンシューマー
│ │ └── queues.module.ts: キューモジュール定義
│ └── roles
│ ├── bot.module.ts: bot・Webサーバーモジュール定義
│ ├── cli.module.ts: CLIモジュール定義
│ └── shared.module.ts: 共有モジュール
├── tsconfig.build.json: ビルド設定ファイル
└── tsconfig.json: TypeScriptの設定ファイル
この記事で行うこと
- Discord.jsのビルドをNestJSに任せよう
- devコマンドでbotを起動してみよう
- チャンネルに通知を送るサービスを設定しよう
- Redisとbullmqをセットアップしよう
- ジョブキューのコンシューマーを作成しよう
- サーバーイベントで通知をトリガーしよう
- CLIsコマンドで通知をトリガーしよう
- fly.ioでデプロイ
ステップ1: Discord.jsのビルドをNestJSに任せよう
まずはNestJSをセットアップし、botを内蔵してみましょう。
既存のbotを作る上で、rollupやviteのセットアップで躓いた方も多いと思います。NestJSを使うことで、nest CLIのビルドコマンドが使えるため、バンドラの設定が楽になります。
まずはモノレポをセットアップ
botだけを開発する場合も、pnpmのモノレポをセットアップすることをおすすめします。複数のフレームワークを切り替える際に、段階的な移行が楽になります。
この記事では apps/bot
にbotを立ち上げます。
pnpm init
mkdir apps
touch pnpm-workspace.yaml
packages:
- 'apps/*'
新しいNestJSアプリを作成します。 (モノレポ用の nest g app
は意図しない構造になるため使いません)
pnpm add @nestjs/cli
pnpm nest new bot
cd apps/bot
環境変数の記載
.env
にbotトークン等の環境変数を記載します。
touch .env
後述する理由により、一部の環境変数ではダブルアンダースコアを使います。
APP_ENV=local
COMMIT_SHA=HEAD
DISCORD__TOKEN=<トークン>
REDIS__URL=redis://localhost:6379
config.tsを作成
必要なライブラリを追加。
pnpm add class-transformer class-validator
mkdir src/frameworks
touch src/frameworks/config.ts
config.tsでは、環境変数の型チェックを行いつつ、扱いやすいように分類を指定します。
ダブルアンダースコアがなぜ入れ子のオブジェクトに変換されるのかは後述します。
参考:
export class DiscordConfig {
/** DISCORD__TOKEN */
@IsString()
public readonly token!: string;
}
export class RedisConfig {
/** REDIS__URL */
@IsString()
public readonly url!: string;
}
enum AppEnv {
testing = "testing",
local = "local",
staging = "staging",
production = "production",
}
export class RootConfig {
@Type(() => DiscordConfig)
@ValidateNested()
public readonly discord!: DiscordConfig;
@Type(() => RedisConfig)
@ValidateNested()
public readonly redis!: RedisConfig;
/** PORT */
@IsNumber()
public readonly port: number = 3000;
/** APP_ENV */
@IsEnum(AppEnv)
public readonly app_env: AppEnv;
/** COMMIT_SHA */
@IsString()
public readonly commit_sha!: string;
}
sharedモジュールでコンフィグを読み取る
コンフィグをアプリで使うには、アプリ全体で共通のモジュールに登録する必要があります。
ここでは SharedModule
を roles
ディレクトリ内に作成します。(思いつかなかったのでロールとしていますが、別になんでもいいです)
pnpm add nest-typed-config
mkdir src/roles
pnpm nest g module shared roles --flat --no-spec
nest-typed-config
というライブラリをimportします。このライブラリは、ダブルアンダースコアで区切った環境変数を構造化します。.env
の他にもYAMLファイル等を使用できるそうです。
/**
* ロール共通で使用するほか、
* テスト時のモジュールでもimportする必要がある
*/
export const typedConfigModuleOptions = {
schema: RootConfig,
load: dotenvLoader({
separator: "__", // 環境変数の区切り文字
// 大文字の環境変数を小文字に変換して取得できるように
// 例: DISCORD__TOKEN -> discord.token
keyTransformer: (key: string) => key.toLowerCase(),
}),
} satisfies TypedConfigModuleOptions;
@Global()
@Module({
imports: [
TypedConfigModule.forRoot(typedConfigModuleOptions),
],
providers: [],
exports: [],
})
export class SharedModule {}
botモジュールでトークンとIntentsの設定
次にnecord
とdiscord.js
をインストールします。
pnpm add necord discord.js
次に、BotModule
を roles
ディレクトリ内に作成します。以降、既存のAppModuleは不要です (後でCliModuleという「necordなしのルートモジュール」を作るので、区別のためにBotModuleと命名)
pnpm nest g module bot roles --flat --no-spec
botモジュールでは、Discord.jsのトークンとIntentsを設定します。 サンプルはプライベートbot想定なので、様々なPrivileged Intentsが有効になっています。
また、SharedModule
で設定した RootConfig
を注入することで、discord.token
= DISCORD__TOKEN
環境変数を使っています。
@Module({
imports: [
// Discord Botの設定
// https://github.com/necordjs/necord
NecordModule.forRootAsync({
inject: [RootConfig],
useFactory: async (rootConfig: RootConfig) => {
return {
token: rootConfig.discord.token,
// @see https://discord.com/developers/docs/events/gateway#list-of-intents
// @see https://scrapbox.io/discordjs-japan/Gateway_Intents_%E3%81%AE%E5%88%A9%E7%94%A8%E3%81%AB%E9%96%A2%E3%81%99%E3%82%8B%E3%82%AC%E3%82%A4%E3%83%89
intents: [
"Guilds", // 必須
"GuildMessages", // テキストベースチャンネルの更新情報等
"DirectMessages", // DMの更新情報等
"GuildMembers", // (Privileged) メンバー一括取得等
"MessageContent", // (Privileged) メッセージ内容
],
};
},
}),
SharedModule,
],
controllers: [],
})
export class BotModule {}
この時点で以下の構造になります。
- frameworks
- config.ts
- roles
- bot.module.ts
- shared.module.ts
- main.ts
main.tsでbotモジュールをロード
main.tsではAppModuleではなくBotModuleをロードするようにします。ついでにWinstonロガーを入れておきます。(任意)
pnpm add nest-winston
async function bootstrap() {
const appName = "bot";
const appModule = BotModule;
const app = await NestFactory.create(appModule, {
// ロガーをWinstonで置換する
// https://github.com/gremo/nest-winston?tab=readme-ov-file#replacing-the-nest-logger-also-for-bootstrapping
logger: WinstonModule.createLogger({
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.ms(),
nestWinstonModuleUtilities.format.nestLike(appName, {
colors: true,
prettyPrint: true,
processId: true,
appName: true,
}),
),
}),
],
}),
});
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
ひとまず「Discord.jsのビルドをNestJSに任せる」ことができたので、ビルドしてみましょう。なお、サンプルリポジトリではTurborepoでビルドしているので、参考にしてみてください。
pnpm build
ステップ2. devコマンドでbotを起動してみよう
pnpm dev
でNestJSを起動できますが、何のログもないため、botが起動できたか不安だと思います。ClientReady
イベントに反応させてみましょう。
pnpm nest g module discord-bot frameworks
まずはDiscordBotモジュールを作成し、DiscordBotServiceを提供します。
@Module({
imports: [],
providers: [DiscordBotService],
exports: [],
})
export class DiscordBotModule {}
サービスクラスでは、このように @Once
デコレータをつけてイベントの処理を記述します。関数の引数には @Context
デコレータを付けてイベントの情報を取ります。
@Injectable()
export class DiscordBotService {
private readonly logger = new Logger(DiscordBotService.name);
@Once(Events.ClientReady)
public onReady(@Context() [client]: ContextOf<"ready">) {
this.logger.log(`Bot logged in as ${client.user.username}`);
}
}
あとはBotModuleにDiscordBotModuleを登録してください。
import { Module } from "@nestjs/common";
import { NecordModule } from "necord";
+ import { DiscordBotModule } from "@/frameworks/discord-bot/discord-bot.module";
import { RootConfig } from "../frameworks/config";
import { SharedModule } from "./shared.module";
@Module({
imports: [
+ DiscordBotModule,
この状態でpnpm dev
すると、下記のようなログが表示されるはずです。
LOG [DiscordBotService] Bot logged in as <Discord botの表示名> +1s
ステップ3. チャンネルに通知を送るサービスを設定しよう
DiscordChannelサービスを作成します。
このようにアプリの様々な箇所で使うサービスを作成する際は、インターフェースや注入用シンボルを作成することで、テスト時に簡単にモックできるようにします。
参考:
Discordチャンネルサービス: インターフェース
mkdir -p src/domain/services
import { MessageCreateOptions, MessagePayload } from "discord.js";
export interface IDiscordChannelService {
getDeveloperMentionString(): string;
sendMessageToAdminNotificationChannel(
options: string | MessagePayload | MessageCreateOptions,
): Promise<void>;
sendMessageToUserNotificationChannel(
options: string | MessagePayload | MessageCreateOptions,
): Promise<void>;
}
Discordチャンネルサービス: シンボル
テスト時はモックする想定なので、モジュールごと注入先を返るためのシンボルを定義します。
mkdir src/frameworks/nest
touch src/frameworks/nest/constants.ts
export const DISCORD_CHANNEL_SERVICE = Symbol("DISCORD_CHANNEL_SERVICE");
Discordチャンネルサービス: コンフィグ
次に、必要なコンフィグを書きます。開発者にメンションする状況を想定したユーザーID、通知用チャンネルのIDを追加。フルコードはサンプルリポジトリを御覧ください。
DISCORD__DEVELOPER_USER_ID=<あなたのDiscordユーザーID>
export class DiscordConfig {
/** DISCORD__TOKEN */
@IsString()
public readonly token!: string;
/** DISCORD__DEVELOPER_USER_ID */
@IsString()
public readonly developer_user_id!: string;
/**
* 環境変数をこれ以上増やしたくないのでハードコーディング
*/
@IsNotEmptyObject()
public readonly adminNotificationChannels: Record<AppEnv, string> = {
testing: "000000000000000000", // テスト環境は通知しない
local: "1426831930623791114", // ※これは筆者のサーバーのチャンネルID
staging: "000000000000000000", // 適宜設定すること
production: "000000000000000000",
};
@IsNotEmptyObject()
public readonly userNotificationChannels: Record<AppEnv, string> = {
testing: "000000000000000000", // テスト環境は通知しない
local: "1426831814844219462", // ※これは筆者のサーバーのチャンネルID
staging: "000000000000000000", // 適宜設定すること
production: "000000000000000000",
};
}
Discordチャンネルサービス: 実装
ついに、DiscordChannelServiceを実装します。
pnpm nest g module discord-channel frameworks --no-spec
necord
なしで動作できないので、BotModuleでインポートする想定というコメント。 (※こういった制約は設計でカバーすべきだが、今回はそこまで頭が回らなかったので勘弁)
/**
* 注意:
*
* このモジュールはNecordモジュール無しでは動作できないため、
* 絶対にCLI側 (=shared.ts) にインポートしてはいけない
*/
@Module({
providers: [DiscordChannelService],
exports: [DiscordChannelService],
})
export class DiscordChannelModule {}
サービスクラスは、 @Injectable
デコレータでマーク し、他のモジュールで注入できるようにします。
また、ここでは RootConfig
のほかに Client
を注入しています。 NecordModule
をimportしているモジュールで使う限り、discord.jsのClient
を注入できます。
ここでは this.client.channels.cache.get
によりチャンネルを取得しています。
@Injectable()
export class DiscordChannelService implements IDiscordChannelService {
constructor(
private readonly config: RootConfig,
private readonly client: Client,
) {}
/** 開発者のメンション(末尾に半角スペースを付ける) */
public getDeveloperMentionString(): string {
const developerUserId = this.config.discord.developer_user_id;
return `<@${developerUserId}> `;
}
private async getAdminNotificationChannel() {
const appEnv = this.config.app_env;
const notificationChannelId =
this.config.discord.adminNotificationChannels[appEnv];
return await this.client.channels.cache.get(notificationChannelId);
}
private async getUserNotificationChannel() {
const appEnv = this.config.app_env;
const notificationChannelId =
this.config.discord.userNotificationChannels[appEnv];
return await this.client.channels.cache.get(notificationChannelId);
}
/**
* ※MessagePayloadを自由に組み立てたいので、
* あえてこのメソッドにはshouldMentionDeveloperを渡さない
*/
async sendMessageToAdminNotificationChannel(
options: string | MessagePayload | MessageCreateOptions,
) {
const channel = await this.getAdminNotificationChannel();
if (channel?.isSendable()) {
await channel.send(options);
} else {
throw new Error("管理者用通知チャンネルが存在しません");
}
}
async sendMessageToUserNotificationChannel(
options: string | MessagePayload | MessageCreateOptions,
) {
const channel = await this.getUserNotificationChannel();
if (channel?.isSendable()) {
await channel.send(options);
} else {
await this.sendMessageToAdminNotificationChannel({
content: `ユーザー用通知チャンネルが削除されています`,
});
}
}
}
Discordチャンネルサービス: モック
テストのためのモックモジュールを作成します。サービスの中身はリポジトリを参照ください。
pnpm nest g module discord-channel-mock frameworks --no-spec
最後に、作成したモジュールをBotModuleにimportしてください。
@Module({
imports: [
DiscordBotModule,
+ DiscordChannelModule,
+ DiscordChannelMockModule,
// Discord Botの設定
// https://github.com/necordjs/necord
通知を送信するユースケースを定義
通知を送信するロジックが、外部のトリガー(コマンド、イベント)に依存しないよう、ユースケースに整理しましょう。
mkdir src/domain/use-cases
touch src/domain/use-cases/base-use-case.interface.ts
export interface BaseUseCase {
execute(...args: unknown[]): Promise<unknown>;
}
以下の2種類のユースケースを作成します。
- SendDeployNotificationUseCase -> デプロイ通知
- SendMemberAddNotificationUseCase -> サーバーメンバー追加通知
mkdir src/domain/use-cases/notification
touch src/domain/use-cases/notification/send-deploy-notification.usecase.ts
touch src/domain/use-cases/notification/send-member-add-notification.usecase.ts
ユースケースでは、 @Inject
デコレータでサービスクラスを注入します。 ここでは @Inject(DISCORD_CHANNEL_SERVICE)
としてシンボルを指定します。
@Injectable()
export class SendDeployNotificationUseCase implements BaseUseCase {
constructor(
private readonly config: RootConfig,
@Inject(DISCORD_CHANNEL_SERVICE)
private readonly discordChannelService: DiscordChannelService,
) {}
async execute(pre?: boolean): Promise<void> {
const appEnv = this.config.app_env;
if (pre) {
await this.discordChannelService.sendMessageToAdminNotificationChannel(
"bot更新開始。既存ジョブの進捗に影響がないか注意してください。",
);
return;
}
const embed = new EmbedBuilder({
title: `necordbullmqbot@${appEnv}`,
}).setFields([
{
name: "version",
value: this.config.commit_sha,
},
]);
await this.discordChannelService.sendMessageToAdminNotificationChannel({
content: "bot更新完了。",
embeds: [embed],
});
}
}
@Injectable()
export class SendMemberAddNotificationUseCase implements BaseUseCase {
constructor(
@Inject(DISCORD_CHANNEL_SERVICE)
private readonly discordChannelService: DiscordChannelService,
) {}
async execute(member: GuildMember) {
this.discordChannelService.sendMessageToAdminNotificationChannel({
content: `新規ユーザー参加: ${member.displayName}`,
});
this.discordChannelService.sendMessageToUserNotificationChannel({
content: `${member.displayName}さん、ようこそ!`,
});
}
}
シンボルとサービスクラスの実装を紐づけ
次に、ユースケースを登録するためのモジュールを作成します。
providers
でシンボルと実装の紐づけを行っています。 MOCK環境変数で判断し、 useExisting
で注入するサービスクラスを指定します。
pnpm nest g module notification-use-cases domain/use-cases/notification --flat --no-spec
const useCases = [
SendDeployNotificationUseCase,
SendMemberAddNotificationUseCase,
];
@Module({
imports: [DiscordChannelModule, DiscordChannelMockModule],
providers: [
...useCases,
{
provide: DISCORD_CHANNEL_SERVICE,
useExisting: process.env.MOCK
? DiscordChannelMockService
: DiscordChannelService,
},
],
exports: [...useCases],
})
export class NotificationUseCasesModule {}
この段階ではユースケースを誰も使っていないので、特に変化はありません。
ユースケースは、通知ジョブを実行=消費=コンシュームする時に使用します。
ステップ4: Redisとbullmqをセットアップしよう
ということで、ジョブキューを立ち上げましょう。bullmqパッケージをインストールします。
pnpm add @nestjs/bullmq
ローカル開発用Redis
Redis(valkey)はDockerで起動します。
name: necord-bullmq-example
services:
# bot実行時はこのredisを使ってください
redis:
image: "valkey/valkey:8"
restart: always
ports:
- "${FORWARD_REDIS_PORT:-6379}:6379"
networks:
- necord-bullmq-example
volumes:
- "redis:/data"
healthcheck:
test: ["CMD", "valkey-cli", "ping"]
retries: 3
timeout: 5s
networks:
necord-bullmq-example:
driver: bridge
volumes:
redis:
Sharedモジュールにキューを設定
Sharedモジュールにジョブキューの設定を記載します。
以下の2種類のジョブキューを宣言します。フルコードはサンプルリポジトリを参照してください。
- deploy-notification
- member-add-notification
export enum QueueName {
"deploy-notification" = "deploy-notification",
"member-add-notification" = "member-add-notification",
}
const queueOptions: Record<
keyof typeof QueueName,
Omit<RegisterQueueOptions, "name">
> = {
"deploy-notification": {
defaultJobOptions: {
removeOnComplete: true,
},
},
"member-add-notification": {
defaultJobOptions: {
removeOnComplete: true,
},
},
};
ステップ5: ジョブキューのコンシューマーを作成しよう
ジョブの中身のDTO
ジョブデータをDTOとして定義します。中身はサンプルリポジトリを見てください。
mkdir -p src/domain/interfaces/jobs
touch src/domain/interfaces/jobs/deployNotification.dto.ts
touch src/domain/interfaces/jobs/memberAddNotification.dto.ts
キューを消費するモジュール
gateways
ディレクトリに QueuesModule
を追加します。
mkdir src/gateways
pnpm nest g module queues gateways --no-spec
このモジュールはキューを扱うため、imports内でregisterQueue
する必要があります。 また、使用するユースケースのモジュールもimportしています。
const consumers = [DeployNotificationConsumer, UserAddNotificationConsumer];
@Module({
imports: [
BullModule.registerQueue(getRegisterQueueOptions("deploy-notification")),
BullModule.registerQueue(
getRegisterQueueOptions("member-add-notification"),
),
NotificationUseCasesModule,
],
providers: [...consumers],
})
export class QueuesModule {}
コンシューマクラスの実装
ジョブを実行=消費=コンシュームするコンシューマクラスを実装します。
touch src/gateways/queues/deploy-notification.consumer.ts
touch src/gateways/queues/user-add-notification.consumer.ts
コンシューマークラスは @Processor
デコレータを付け、消費するキューを指定します。process
メソッドで実際の処理を記載します。詳細はドキュメントを参照ください。
ここでは、コンストラクタでユースケースを注入しています。このユースケースをモックする必要はないため、シンボル定義なしで直接注入します。
このようにコンシューマのprocessでは、他にも様々なユースケースを呼び出すことで、例えばメール通知といった様々な処理を追加できます。これがキューを経由する非同期処理の保守上の利点です。また、もう一つの利点として、キューに適切なリトライ間隔を設定する限り、Too Many Requestsを心配せずに様々なアクションを起こせるのです。
@Processor(QueueName["deploy-notification"])
export class DeployNotificationConsumer extends WorkerHost {
private readonly logger = new Logger(DeployNotificationConsumer.name);
public constructor(
private readonly sendDeployNotificationUseCase: SendDeployNotificationUseCase,
) {
super();
}
@OnWorkerEvent("active")
onActive(job: Job) {
this.logger.log(
`Processing job [${job.id}] (${job.name}) with data: ` +
JSON.stringify(job.data),
);
}
async process(job: Job<DeployNotificationDTO>): Promise<void> {
try {
const { pre } = job.data as DeployNotificationDTO;
await this.sendDeployNotificationUseCase.execute(pre);
} catch (e) {
this.logger.error(
`Job ${job.id} of type ${job.name} failed with error: ${e.message}`,
e,
);
// 注意: BullMQのジョブに対してはErrorをThrowすることでリトライを判定する
throw e;
}
}
}
@Processor(QueueName["member-add-notification"])
export class UserAddNotificationConsumer extends WorkerHost {
private readonly logger = new Logger(UserAddNotificationConsumer.name);
public constructor(
private readonly sendMemberAddNotificationUseCase: SendMemberAddNotificationUseCase,
) {
super();
}
@OnWorkerEvent("active")
onActive(job: Job) {
this.logger.log(
`Processing job [${job.id}] (${job.name}) with data: ` +
JSON.stringify(job.data),
);
}
async process(job: Job<MemberAddNotificationDTO>): Promise<void> {
try {
const { member } = job.data;
await this.sendMemberAddNotificationUseCase.execute(member);
} catch (e) {
this.logger.error(
`Job ${job.id} of type ${job.name} failed with error: ${e.message}`,
e,
);
// 注意: BullMQのジョブに対してはErrorをThrowすることでリトライを判定する
throw e;
}
}
}
最後に、QueuesModuleをBotModuleにimportしてください。
}),
+ QueuesModule,
SharedModule,
ステップ6: サーバーイベントで通知をトリガーしよう
ついに通知をトリガーします。通知をトリガーするには、通知用キューにジョブを追加します。
サーバーメンバー追加イベントをウォッチし、通知をトリガーしてみましょう。
DiscordEventsモジュールの作成
pnpm nest g module discord-events gateways --no-spec
touch src/gateways/discord-events/guild-member-add.event.ts
imports内でregisterQueue
を忘れずに。
const events = [GuildMemberAddEvent];
@Module({
imports: [
BullModule.registerQueue(
getRegisterQueueOptions("member-add-notification"),
),
],
providers: [...events],
exports: [...events],
})
export class DiscordEventsModule {}
モジュールではイベント毎にクラスを分けると管理しやすいでしょう。
以下のクラスでは、メンバー追加イベントをウォッチしています。(厳密には参加のお知らせを監視している)
@Injectable()
export class GuildMemberAddEvent {
private readonly logger = new Logger(GuildMemberAddEvent.name);
constructor(
@InjectQueue(QueueName["member-add-notification"])
private userAddNotificationQueue: Queue<MemberAddNotificationDTO>,
) {}
/**
* `GuildMemberAdd` はdeprecatedのため
* 参加メッセージで判断する
* https://zenn.dev/r64/articles/785f3824d0477f
*/
@On(Events.MessageCreate)
public async onGuildMemberAdd(
@Context() [message]: ContextOf<Events.MessageCreate>,
) {
if (message.type === MessageType.UserJoin && message.member) {
this.logger.debug("triggering onGuildMemberAdd");
await this.userAddNotificationQueue.add("notify", {
member: message.member,
});
}
}
}
そのクラスでキューを扱う場合、@InjectQueue
デコレータが必要になります。 また、Queueの型引数にジョブのDTOを指定します。
constructor(
+ @InjectQueue(QueueName["member-add-notification"])
private userAddNotificationQueue: Queue<MemberAddNotificationDTO>,
) {}
最後に、DiscordEventsModuleをBotModuleにインポートします。
@Module({
imports: [
ControllersModule,
DiscordBotModule,
+ DiscordEventsModule,
DiscordChannelModule,
DiscordChannelMockModule,
インポートした時点で、botがイベントに反応するようになります。また、QueuesModuleがジョブを消費すれば、下記のように通知が届くはずです。
ステップ7: CLIコマンドで通知をトリガーしよう
任意のタイミングで操作するにあたり、コマンドで操作できれば運用が楽です。APIルートを露出する手もありますが、APIキーなどの用意が面倒です。
スラッシュコマンドはイベントみたいに定義できます
需要があるのは理解していますが、スラッシュコマンドの作り方は割愛します。
necordのドキュメントを見ると、イベントの定義とあまり変わらないのが分かると思います。参考に実装してください。
CLIコマンドモジュールの作成
CLIコマンドはNestJSの標準機能ではなく、nest-commander
というパッケージを使います。
pnpm add nest-commander
mkdir src/gateways/commands
pnpm nest g module commands gateways
例によってキューをregisterします。
@Module({
imports: [
BullModule.registerQueue(getRegisterQueueOptions("deploy-notification")),
],
providers: [DeployCommand],
})
export class CommandsModule {}
デプロイコマンドの定義
コマンドは1つのクラスととして定義します。
mkdir -p src/gateways/commands/notification
touch src/gateways/commands/notification/deploy.command.ts
@Command
デコレータでコマンドを定義します。デプロイコマンドは --pre
オプションで「デプロイ前通知」も送れるようにします。
type CommandOptions = {
pre?: boolean;
};
@Command({
name: "notification:deploy",
description: "デプロイ通知を飛ばす",
})
export class DeployCommand extends CommandRunner {
private readonly logger = new Logger(DeployCommand.name);
constructor(
@InjectQueue(QueueName["deploy-notification"])
private deployNotificationQueue: Queue<DeployNotificationDTO>,
) {
super();
}
@Option({
flags: "--pre [bool]",
description: "pre-deployならtrue",
required: false,
})
parsePre(option?: string) {
this.logger.log(`parsePre called with option: ${option}`);
return !!option;
}
async run(_args: string[], options?: CommandOptions): Promise<void> {
await this.deployNotificationQueue.add("deploy", {
pre: options?.pre ?? false,
});
}
}
CLIモジュールの作成
CLIモジュールでは、CommandsModuleとSharedModuleのみをインポートします。 これによりbotの二重起動を防ぎます。
pnpm nest g module cli roles --flat --no-spec
/**
* CLIモジュールはNecordやqueueのConsumerを使用しない
*/
@Module({
imports: [CommandsModule, SharedModule],
controllers: [],
providers: [],
})
export class CliModule {}
CLI用エントリーポイントの作成
ビルドしたら node dist/cli <コマンド>
でコマンドを叩けるようにします。bin/rails
とかartisan
みたいなファイルだと思ってください。
touch src/cli.ts
async function bootstrap() {
const appName = "cli";
const appModule = CliModule;
const app = await CommandFactory.createWithoutRunning(appModule, {
// ロガーをWinstonで置換する
// https://github.com/gremo/nest-winston?tab=readme-ov-file#replacing-the-nest-logger-also-for-bootstrapping
logger: WinstonModule.createLogger({
transports: [
new winston.transports.Console({
format: winston.format.combine(
winston.format.timestamp(),
winston.format.ms(),
nestWinstonModuleUtilities.format.nestLike(appName, {
colors: true,
prettyPrint: true,
processId: true,
appName: true,
}),
),
}),
],
}),
});
await CommandFactory.runApplication(app);
// これがないとプロセスが永続してしまう
app.close();
}
bootstrap();
これで以下のような構造になりました。
- src/
- roles/
- bot.module.ts
- cli.module.ts
- shared.module.ts
- main.ts
- cli.ts
- roles/
コマンドを叩いてみよう
pnpm dev
しながら二窓で以下のコマンドを実行し、デプロイ通知が来るか確かめてください。なお、モノレポルートから叩くと.env
が読まれずエラーになります。
node dist/cli "notification:deploy" "--pre"
node dist/cli "notification:deploy"
ステップ8: fly.ioでデプロイ
簡単のためfly.ioを例示しますが、Redisが使えればデプロイ先はどこでもよいです。
プロダクションDockerfile
プロダクションのDockerfileを作ります。モノレポとDockerfileの併用は、pnpm deployなど色々な方法がありますが、今回はturbo pruneを使います。詳細はサンプルリポジトリを参照してください。
cd ../..
pnpm add turbo
pnpm turbo init
touch Dockerfile.bot
Dockerfileは下記の記事のものを参考にしています。turbo prune
コマンドにより 必要なアプリだけを抜粋したモノレポ を作っているんですね。
fly.ioの設定ファイル
touch apps/bot/fly.{staging,production}.toml
最小限のスペックでデプロイするよう、以下のような設定ファイルを記述します。Dockerfileのパスに注意。リージョンは適宜変えてください。
app = '<アプリ名>'
primary_region = 'nrt'
[build]
dockerfile = '../../Dockerfile.bot'
[env]
APP_ENV = 'staging'
DISCORD__DEVELOPER_USER_ID = '<あなたのDiscordユーザーID>'
LOG_LEVEL = 'error'
[http_service]
internal_port = 3000
force_https = true
auto_stop_machines = 'off'
auto_start_machines = true
min_machines_running = 1
processes = ['app']
[[vm]]
size = 'shared-cpu-1x'
memory = '256mb'
アプリとRedisの作成
コンフィグファイルを使ってアプリを作成します。この段階ではシークレットが設定されただけで、ロールアウトまで稼働しません。
fly launch --copy-config -c apps/bot/fly.staging.toml \
--no-deploy \
--no-github-workflow
fly redis create -r nrt \
--enable-eviction --no-replicas \
-n <アプリ名>-redis
# configの都合でダブルアンダースコアになっているため注意
fly secrets set --stage -c apps/bot/fly.staging.toml \
DISCORD__TOKEN="<botトークン>" \
REDIS__URL="<redis createコマンドで表示されたURL>"
ロールアウト
ロールアウトによりbotが稼働します。`ha=falseは必ず指定してください。 fly.ioのオートスケール機能でマシンが増えないようにするためです。
fly deploy -c apps/bot/fly.staging.toml --ha=false \
--env COMMIT_SHA="$(git rev-parse HEAD)"
fly logs -a <アプリ名>
継続的デプロイ
例えばGitHub Actionsでは以下のようにデプロイします。(再利用可能ジョブの場合。inputなどは省略)
fly ssh console
コマンドによりデプロイ通知を発火しています。
steps:
- uses: actions/checkout@v4
- uses: superfly/flyctl-actions/setup-flyctl@master
# pre-deployコマンド
- run: >
flyctl ssh console -a ${{ env.APP_NAME }}
-C "node dist/cli notification:deploy --pre"
# ha=falseでマシンの複製を防止する
- run: >
flyctl deploy --remote-only
-c apps/bot/fly.${{ inputs.environment }}.toml --ha=false
--env COMMIT_SHA=${{ github.sha }}
# deployコマンド
- run: >
flyctl ssh console -a ${{ env.APP_NAME }}
-C "node dist/cli notification:deploy"
Upstash RedisとBullMQのコストについて
fly.ioはUpstash Redisと連携しており、上記のコマンドでUpstashのRedisインスタンスが立ち上がります。
ダッシュボードの「Upstash Redis」からRedisの使用量を確認できます。
冒頭で警告しましたが、BullMQとUpstashのPay as you goは相性が悪く、ジョブがなくてもコストが増えます。 これはポーリングにより高頻度でリクエストが走るためです。
例えば2025年10月13日時点の料金体系とレートでは、8時間で30円ほどかかっています。
これを回避するためには、私のほかの記事で触れている「Kamal」といったツールでRedisを立ち上げたり、Upstashの代替サービスを探す、もしくはそもそもAWS SQS等を検討すべきでしょう。
おわりに
botの機能に対してあまりにもオーバーエンジニアリングになっています。ただ、NestJSのアーキテクチャのおかげで保守性が上がり、ジョブキューを活用すればOpenAIなど時間のかかるAIサービスとの通信をする際に、非同期処理の真価が発揮されるでしょう。ぜひnecordで大規模なbotを作ってみてください。
Discussion