💎

Sapphire 使ってみた (discord.js)

2023/01/27に公開

Sapphire とは?

https://www.sapphirejs.dev/

  • discord.js の中規模~大規模向けのフレームワーク
  • discord.js メンテナーが開発
  • 1 モジュール 1 ファイル、宣言的
  • HMR, i18n 等は公式プラグインで利用可能
  • v14 対応

使ってみた感想

  • コードの見通しが良くなる
  • Bot の GlobalState っぽいcontainerが便利
  • 手軽さ・実用度・完成度は discord.py の方が圧倒的
  • コア機能が出来ただけ、融通が効かない
  • Button や Modal が抽象化できてない
  • 使う人、作る人が増えたら変わりそう

環境

VSCode の devcontainer を使用している。イメージは Node.js & JavaScript@18

環境 バージョン
Node.js 18.13.0
@sapphire/cli 1.3.1
@sapphire/framework 4.0.2
discord.js 14.7.1

プロジェクト作成

プロジェクト用に新規ディレクトリを作成し、その中で次を実行。

npx @sapphire/cli new

プロジェクト名を . と入力すると、npm init と同様にカレントディレクトリにテンプレートが展開される。
サンプルコードがいくつか含まれているが、不要なものは消してよい。

node ➜ /workspaces/sapphire-test $ npx @sapphire/cli new
Need to install the following packages:
  @sapphire/cli@1.3.1
Ok to proceed? (y)
✔ What's the name of your project? … .
✔ Choose a language for your project › TypeScript (Recommended)
✔ Choose a template for your project › Default template (Recommended)
✔ What format do you want your config file to be in? › JSON
✔ What package manager do you want to use? › npm
✔ Do you want to create a git repository for this project? … no
✔ Cloning the repository
✔ Setting up the project
✔ Installing dependencies using npm
Done!
node ➜ /workspaces/sapphire-test $ tree -a -L 2 -I node_modules
.
├── .devcontainer
│   └── devcontainer.json
├── .gitignore
├── package.json
├── package-lock.json
├── .prettierignore
├── README.md
├── .sapphirerc.json
├── src
│   ├── commands
│   ├── .env
│   ├── index.ts
│   ├── lib
│   ├── listeners
│   ├── preconditions
│   └── routes
└── tsconfig.json

足りない物追加

必ず追加で @sapphire/cli と、お好みで rimraf 等をインストールしておく。

npm i -D @sapphire/cli rimraf

cli コマンドが npx sapphire g ... と長ったらしいので、alias を設定する。
以降は sp g ... と打つだけ。 GUUD

devcontainer.json
"postCreateCommand": "echo alias 'sp=\"npx sapphire\"' >> ~/.bashrc"

コンポーネント作成

https://www.sapphirejs.dev/docs/Guide/CLI/generating-components
Sapphire ではいわゆるモジュールのことをComponentもしくはPieceと呼ぶ。
cli から各コンポーネントのテンプレートを作成できる。
ただし、cli@v1.3.1 では Button 等のインタラクションハンドラは作成できないので、後述するカスタムテンプレートでなんとかする。

コマンド構文

sapphire g <テンプレート名> <コンポーネント名>

テンプレート名一覧

  • messagecommand
  • slashcommand
  • contextmenucommand
  • listener
  • argument
  • precondition
スラッシュコマンド作成の例
sapphire g slashcommand Test
src/commands/Test.ts
import { ApplyOptions } from '@sapphire/decorators';
import { Command } from '@sapphire/framework';

@ApplyOptions<Command.Options>({
  description: 'A basic slash command'
})
export class UserCommand extends Command {
  public override registerApplicationCommands(registry: Command.Registry) {
    registry.registerChatInputCommand((builder) =>
      builder //
        .setName(this.name)
        .setDescription(this.description)
    );
  }

  public override async chatInputRun(interaction: Command.ChatInputInteraction) {
    return await interaction.reply({ content: 'Hello world!' });
  }
}

カスタムテンプレート

https://www.sapphirejs.dev/docs/Guide/CLI/custom-templates

例として Button のテンプレートを作成する。

  1. プロジェクト直下にtemplatesディレクトリを作成

  2. templatesにテンプレートファイルを作成
    ファイル名の形式は <テンプレート名>.<(js|ts)>.sapphire
    独自仕様として、ファイルの先頭にカテゴリ名を記述する。また、{{name}}がコマンドの名前引数に置換される。

templates/button.ts.sapphire
{ "category": "interaction" }
---
import { ApplyOptions } from '@sapphire/decorators'
import {
  InteractionHandler,
  InteractionHandlerOptions,
  InteractionHandlerTypes,
} from '@sapphire/framework'
import type { ButtonInteraction } from 'discord.js'

@ApplyOptions<InteractionHandlerOptions>({
  name: '{{name}}',
  enabled: true,
  interactionHandlerType: InteractionHandlerTypes.Button,
})
export class ButtonHandler extends InteractionHandler {
  public override parse(interaction: ButtonInteraction) {
    if (interaction.customId !== 'my-awesome-button') return this.none()

    return this.some()
  }

  public async run(interaction: ButtonInteraction) {
    await interaction.reply({
      content: 'Hello from a button interaction handler!',
      ephemeral: true,
    })
  }
}
  1. .sapphirercを変更する。
.sapphirerc.json
{
  "projectLanguage": "ts",
  "locations": {
    "base": "src",
    "arguments": "arguments",
    "commands": "commands",
    "listeners": "listeners",
    "preconditions": "preconditions",
    // "カテゴリ名": "出力ディレクトリ名"
+   "interaction": "interaction-handlers"
  },
  "customFileTemplates": {
+   "enabled": true,
+   "location": "templates"
  }
}
  1. コマンドを実行して確認
sapphire g button MyButton
src/interaction-handlers/MyButton.ts
import { ApplyOptions } from '@sapphire/decorators'
import {
  InteractionHandler,
  InteractionHandlerOptions,
  InteractionHandlerTypes,
} from '@sapphire/framework'
import type { ButtonInteraction } from 'discord.js'

@ApplyOptions<InteractionHandlerOptions>({
  name: 'MyButton',
  enabled: true,
  interactionHandlerType: InteractionHandlerTypes.Button,
})
export class ButtonHandler extends InteractionHandler {
  public override parse(interaction: ButtonInteraction) {
    if (interaction.customId !== 'my-awesome-button') return this.none()

    return this.some()
  }

  public async run(interaction: ButtonInteraction) {
    await interaction.reply({
      content: 'Hello from a button interaction handler!',
      ephemeral: true,
    })
  }
}

Prettier オーバーライド

package.json"prettier": "@sapphire/prettier-config"を削除。
プロジェクトルートに.prettierrc.jsを作成。

.prettierrc.js
module.exports = {
  // 継承
  ...require("@sapphire/prettier-config"),
  // オーバーライド
  printWidth: 100,
  semi: false,
  tabWidth: 2,
  useTabs: false,
  trailingComma: "es5",
}

HMR

https://github.com/sapphiredev/plugins/tree/main/packages/hmr
commandslisteners内の js ファイルが変更・削除されると再読み込みするプラグイン。
npm starttsc -wを同時に実行すると、Bot をログインさせたままコードの変更がリアルタイムに反映されるようになる。
後述の問題点に注意。

インストール

npm i @sapphire/plugin-hmr

読み込み

setup.ts
import '@sapphire/plugin-hmr/register'

devcontainer では fsevents がうまく動かず、ポーリングを指定したら動作した。

ポーリング設定例
index.ts
const client = new SapphireClient({
  hmr: {
    enabled: true,
    usePolling: true
  }
})
tsconfig.json
{
  "watchOptions": {
    "watchFile": "DynamicPriorityPolling",
    "watchDirectory": "DynamicPriorityPolling"
  }
}

watch モード設定

HMR を有効にしたら npm scripts を変更しておくと良い。

package.json
{
  "scripts": {
    "build": "tsc",
    "clean": "rimraf dist",
    "dev": "run-s build start",
    "dev:watch": "run-p start watch",
    "format": "prettier --write \"src/**/*.ts\"",
    "start": "node dist/index.js",
    "watch": "tsc -w"
  }
}

HMR の問題点

  1. chokidar の監視解除を忘れている。プロセスが死なない可能性。
  2. 監視するのはコンポーネントファイルの変更のみ。
  3. require したファイルの変更は反映されない。

3.に非常に悩まされた。原因は sapphiredev/pieces にあり、ロード時に依存モジュールの require cache の消し忘れ。依存モジュールのキャッシュを消すと正しく反映される。
コンポーネントが独立しておらず、モジュールとして機能していないとも言える。誰も使ってないのか…?

修正例:https://github.com/ookkoouu/pieces/commit/8011b684506313df4bfbd5e108b9df806378e790

デコレーター

https://www.sapphirejs.dev/docs/Documentation/api-utilities/modules/decorators_src
コンポーネントのオプションを宣言的に置き換えられる。
ただし使えるのはコンストラクタのオプションとコマンドパーミッションのみ。

MyListener.ts
+import { ApplyOptions } from '@sapphire/decorators'
 import { Events, Listener, ListenerOptions } from '@sapphire/framework'
 import { Message } from 'discord.js'

+@ApplyOptions<Listener.Options>({
+  name: "MyListener",
+  event: Events.MessageCreate,
+})
 export class UserEvent extends Listener {
-  public constructor(context: Listener.Context, options: Listener.Options) {
-    super(context, {
-      ...options,
-      name: "MyListener",
-      event: 'messageCreate',
-    })
-  }
   public run(message: Message) {}
 }

Dockerfile で実行

HMR を使用しない想定。
ビルドしたコードを全てイメージの中に入れている。

Dockerfile
FROM node:18-slim as builder
COPY . .
RUN npm ci && \
    npm run build:release

FROM node:18-slim as deps
COPY package*.json .
RUN npm ci --omit=dev

FROM node:18-slim as prod
ENV NODE_ENV=production
WORKDIR /home/node
RUN apt-get update && \
    apt-get install -qq -y --no-install-recommends \
    tini ca-certificates && \
    rm -rf /var/lib/apt/lists/*

COPY --from=deps --chown=node:node node_modules /home/node/node_modules
COPY --from=builder --chown=node:node package.json /home/node/package.json
COPY --from=builder --chown=node:node dist /home/node/dist

USER node
ENTRYPOINT [ "/usr/bin/tini", "--" ]
CMD [ "npm", "run", "start" ]

docker-compose で実行

HMR を使用する想定。
Dockerfile でtiniをインストールしておく。distpackage.jsonを compose からマウントすることでHMRを効かせる。

Dockerfile
# ディレクトリにあるファイル
# node_modules, package.json, dist, Dockerfile, docker-compose.yml
FROM node:18-slim as prod
ENV NODE_ENV=production
WORKDIR /home/node
RUN apt-get update && \
    apt-get install -qq -y --no-install-recommends \
    tini ca-certificates && \
    rm -rf /var/lib/apt/lists/*

USER node
ENTRYPOINT [ "/usr/bin/tini", "--" ]
CMD [ "npm", "run", "start" ]
docker-compose.yml
version: '3.8'

services:
  bot:
    container_name: MySapphireBot
    build: 
      context: .
      dockerfile: Dockerfile
    restart: always
    env_file: .env
    volumes:
      - ./node_modules:/home/node/node_modules
      - ./dist:/home/node/dist
      - ./package.json:/home/node/package.json

Discussion