🌊

necordとbullmqで、ジョブキューCLI併用Discord botの構築

に公開

Discord.jsによるbotの構築は、ある程度のベストプラクティスがGuideで解説されているものの、ディレクトリ構造や開発環境の構築は開発者の選択に委ねられています。

この記事では、necordを活用し、NestJSのモジュールとしてbotを起動します。また、ジョブキューを挟むことで、非同期処理を実現し、イベント処理の肥大化を防ぎます。

※necordに関する日本語の情報は、おそらく当記事が初出です。ここでは全てをカバーできていません。あくまで簡単なイベント反応の例のみ掲載します。

サンプルリポジトリ

サンプルではヘルスチェックやE2Eテストの定義も含んでいますが、記事では割愛します。

https://github.com/andhisan/necord-bullmq-example

この記事が想定しているbotと開発環境

  • Node.js@22
  • pnpm@10
  • 内輪のプライベートbot
  • 既にRedisを使っている

今回作るbotの機能

ユーザー追加通知

デプロイ(前)通知

GitHub Actionsでコマンドを発火することで、botの更新を通知します。

今回作るbotのアーキテクチャ

「デーモンでbotを動かしつつ、CLIコマンドでも操作できる」という要件を設定します。

ただ、AppModuleにDiscord.jsを組み込むと、CLIコマンドを叩くたびにbotが二重起動してしまうます。これを防ぐため、 BotModuleCliModule でエントリーポイントを分けます。

  • 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
pnpm-workspace.yaml
packages:
  - 'apps/*'

新しいNestJSアプリを作成します。 (モノレポ用の nest g app は意図しない構造になるため使いません)

pnpm add @nestjs/cli
pnpm nest new bot
cd apps/bot

環境変数の記載

.envにbotトークン等の環境変数を記載します。

touch .env

後述する理由により、一部の環境変数ではダブルアンダースコアを使います。

.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では、環境変数の型チェックを行いつつ、扱いやすいように分類を指定します。

ダブルアンダースコアがなぜ入れ子のオブジェクトに変換されるのかは後述します。

参考:
https://zenn.dev/waddy/articles/nestjs-configuration-service

frameworks/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モジュールでコンフィグを読み取る

コンフィグをアプリで使うには、アプリ全体で共通のモジュールに登録する必要があります。

ここでは SharedModuleroles ディレクトリ内に作成します。(思いつかなかったのでロールとしていますが、別になんでもいいです)

pnpm add nest-typed-config
mkdir src/roles
pnpm nest g module shared roles --flat --no-spec

nest-typed-config というライブラリをimportします。このライブラリは、ダブルアンダースコアで区切った環境変数を構造化します。.envの他にもYAMLファイル等を使用できるそうです。

https://github.com/Nikaple/nest-typed-config

roles/shared.module.ts
/**
 * ロール共通で使用するほか、
 * テスト時のモジュールでも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の設定

次にnecorddiscord.jsをインストールします。

https://github.com/necordjs/necord

pnpm add necord discord.js

次に、BotModuleroles ディレクトリ内に作成します。以降、既存の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 環境変数を使っています。

roles/bot.module.ts
@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 {}

https://necord.org/start

この時点で以下の構造になります。

  • frameworks
    • config.ts
  • roles
    • bot.module.ts
    • shared.module.ts
  • main.ts

main.tsでbotモジュールをロード

main.tsではAppModuleではなくBotModuleをロードするようにします。ついでにWinstonロガーを入れておきます。(任意)

pnpm add nest-winston
main.ts
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を提供します。

frameworks/discord-bot/discord-bot.module.ts
@Module({
	imports: [],
	providers: [DiscordBotService],
	exports: [],
})
export class DiscordBotModule {}

サービスクラスでは、このように @Once デコレータをつけてイベントの処理を記述します。関数の引数には @Context デコレータを付けてイベントの情報を取ります。

frameworks/discord-bot/discord-bot.service.ts
@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を登録してください。

roles/bot.module.ts
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サービスを作成します。

このようにアプリの様々な箇所で使うサービスを作成する際は、インターフェースや注入用シンボルを作成することで、テスト時に簡単にモックできるようにします。

参考:

https://github.com/peterkracik/nestjs-clean-architecture

Discordチャンネルサービス: インターフェース

mkdir -p src/domain/services
domain/services/discord-channel.service.interface.ts
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
frameworks/nest/constants.ts
export const DISCORD_CHANNEL_SERVICE = Symbol("DISCORD_CHANNEL_SERVICE");

Discordチャンネルサービス: コンフィグ

次に、必要なコンフィグを書きます。開発者にメンションする状況を想定したユーザーID、通知用チャンネルのIDを追加。フルコードはサンプルリポジトリを御覧ください。

.env
DISCORD__DEVELOPER_USER_ID=<あなたのDiscordユーザーID>
frameworks/config.ts
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でインポートする想定というコメント。 (※こういった制約は設計でカバーすべきだが、今回はそこまで頭が回らなかったので勘弁)

frameworks/discord-channel/discord-channel.module.ts
/**
 * 注意:
 *
 * このモジュールは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 によりチャンネルを取得しています。

frameworks/discord-channel/discord-channel.service.ts
@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してください。

roles/bot.module.ts
@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
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) としてシンボルを指定します。

domain/use-cases/notification/send-deploy-notification.usecase.ts
@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],
		});
	}
}

domain/use-cases/notification/send-member-add-notification.usecase.ts
@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
domain/use-cases/notification/notification-use-cases.module.ts
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で起動します。

docker-compose.yml
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
roles/shared.module.ts
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しています。

gateways/queues/queues.module.ts
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 メソッドで実際の処理を記載します。詳細はドキュメントを参照ください。

https://docs.nestjs.com/techniques/queues

ここでは、コンストラクタでユースケースを注入しています。このユースケースをモックする必要はないため、シンボル定義なしで直接注入します。

このようにコンシューマのprocessでは、他にも様々なユースケースを呼び出すことで、例えばメール通知といった様々な処理を追加できます。これがキューを経由する非同期処理の保守上の利点です。また、もう一つの利点として、キューに適切なリトライ間隔を設定する限り、Too Many Requestsを心配せずに様々なアクションを起こせるのです。

gateways/queues/deploy-notification.consumer.ts
@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;
		}
	}
}
gateways/queues/user-add-notification.consumer.ts
@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してください。

roles/bot.module.ts
		}),
+		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 {}

モジュールではイベント毎にクラスを分けると管理しやすいでしょう。

以下のクラスでは、メンバー追加イベントをウォッチしています。(厳密には参加のお知らせを監視している)

https://zenn.dev/r64/articles/785f3824d0477f

gateways/discord-events/guild-member-add.event.ts
@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にインポートします。

roles/bot.module.ts
@Module({
	imports: [
		ControllersModule,
		DiscordBotModule,
+		DiscordEventsModule,
		DiscordChannelModule,
		DiscordChannelMockModule,

インポートした時点で、botがイベントに反応するようになります。また、QueuesModuleがジョブを消費すれば、下記のように通知が届くはずです。

ステップ7: CLIコマンドで通知をトリガーしよう

任意のタイミングで操作するにあたり、コマンドで操作できれば運用が楽です。APIルートを露出する手もありますが、APIキーなどの用意が面倒です。

スラッシュコマンドはイベントみたいに定義できます

需要があるのは理解していますが、スラッシュコマンドの作り方は割愛します。

necordのドキュメントを見ると、イベントの定義とあまり変わらないのが分かると思います。参考に実装してください。

https://necord.org/interactions/slash-commands

CLIコマンドモジュールの作成

CLIコマンドはNestJSの標準機能ではなく、nest-commanderというパッケージを使います。

https://nest-commander.jaymcdoniel.dev/en/introduction/installation/

pnpm add nest-commander
mkdir src/gateways/commands
pnpm nest g module commands gateways

例によってキューをregisterします。

gateways/commands/commands.module.ts
@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 オプションで「デプロイ前通知」も送れるようにします。

gateways/commands/notification/deploy.command.ts
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
roles/cli.module.ts
/**
 * CLIモジュールはNecordやqueueのConsumerを使用しない
 */
@Module({
	imports: [CommandsModule, SharedModule],
	controllers: [],
	providers: [],
})
export class CliModule {}

CLI用エントリーポイントの作成

ビルドしたら node dist/cli <コマンド> でコマンドを叩けるようにします。bin/railsとかartisanみたいなファイルだと思ってください。

touch src/cli.ts
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

コマンドを叩いてみよう

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 コマンドにより 必要なアプリだけを抜粋したモノレポ を作っているんですね。

https://fintlabs.medium.com/optimized-multi-stage-docker-builds-with-turborepo-and-pnpm-for-nodejs-microservices-in-a-monorepo-c686fdcf051f

fly.ioの設定ファイル

touch apps/bot/fly.{staging,production}.toml

最小限のスペックでデプロイするよう、以下のような設定ファイルを記述します。Dockerfileのパスに注意。リージョンは適宜変えてください。

apps/bot/fly.staging.toml
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等を検討すべきでしょう。


おわりに

https://github.com/andhisan/necord-bullmq-example

botの機能に対してあまりにもオーバーエンジニアリングになっています。ただ、NestJSのアーキテクチャのおかげで保守性が上がり、ジョブキューを活用すればOpenAIなど時間のかかるAIサービスとの通信をする際に、非同期処理の真価が発揮されるでしょう。ぜひnecordで大規模なbotを作ってみてください。

Discussion