Sapphire 使ってみた (discord.js)
Sapphire とは?
- 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
"postCreateCommand": "echo alias 'sp=\"npx sapphire\"' >> ~/.bashrc"
コンポーネント作成
Component
もしくはPiece
と呼ぶ。
cli から各コンポーネントのテンプレートを作成できる。
ただし、cli@v1.3.1 では Button 等のインタラクションハンドラは作成できないので、後述するカスタムテンプレートでなんとかする。
コマンド構文
sapphire g <テンプレート名> <コンポーネント名>
テンプレート名一覧
messagecommand
slashcommand
contextmenucommand
listener
argument
precondition
スラッシュコマンド作成の例
sapphire g slashcommand Test
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!' });
}
}
カスタムテンプレート
例として Button のテンプレートを作成する。
-
プロジェクト直下に
templates
ディレクトリを作成 -
templates
にテンプレートファイルを作成
ファイル名の形式は<テンプレート名>.<(js|ts)>.sapphire
。
独自仕様として、ファイルの先頭にカテゴリ名を記述する。また、{{name}}
がコマンドの名前引数に置換される。
{ "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,
})
}
}
-
.sapphirerc
を変更する。
{
"projectLanguage": "ts",
"locations": {
"base": "src",
"arguments": "arguments",
"commands": "commands",
"listeners": "listeners",
"preconditions": "preconditions",
// "カテゴリ名": "出力ディレクトリ名"
+ "interaction": "interaction-handlers"
},
"customFileTemplates": {
+ "enabled": true,
+ "location": "templates"
}
}
- コマンドを実行して確認
sapphire g button MyButton
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
を作成。
module.exports = {
// 継承
...require("@sapphire/prettier-config"),
// オーバーライド
printWidth: 100,
semi: false,
tabWidth: 2,
useTabs: false,
trailingComma: "es5",
}
HMR
commands
やlisteners
内の js ファイルが変更・削除されると再読み込みするプラグイン。
npm start
とtsc -w
を同時に実行すると、Bot をログインさせたままコードの変更がリアルタイムに反映されるようになる。
後述の問題点に注意。
インストール
npm i @sapphire/plugin-hmr
読み込み
import '@sapphire/plugin-hmr/register'
devcontainer では fsevents がうまく動かず、ポーリングを指定したら動作した。
ポーリング設定例
const client = new SapphireClient({
hmr: {
enabled: true,
usePolling: true
}
})
{
"watchOptions": {
"watchFile": "DynamicPriorityPolling",
"watchDirectory": "DynamicPriorityPolling"
}
}
watch モード設定
HMR を有効にしたら npm scripts を変更しておくと良い。
{
"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 の問題点
- chokidar の監視解除を忘れている。プロセスが死なない可能性。
- 監視するのはコンポーネントファイルの変更のみ。
- require したファイルの変更は反映されない。
3.に非常に悩まされた。原因は sapphiredev/pieces にあり、ロード時に依存モジュールの require cache の消し忘れ。依存モジュールのキャッシュを消すと正しく反映される。
コンポーネントが独立しておらず、モジュールとして機能していないとも言える。誰も使ってないのか…?
修正例:https://github.com/ookkoouu/pieces/commit/8011b684506313df4bfbd5e108b9df806378e790
デコレーター
ただし使えるのはコンストラクタのオプションとコマンドパーミッションのみ。
+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 を使用しない想定。
ビルドしたコードを全てイメージの中に入れている。
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 node_modules /home/node/node_modules
COPY package.json /home/node/package.json
COPY dist /home/node/dist
USER node
ENTRYPOINT [ "/usr/bin/tini", "--" ]
CMD [ "npm", "run", "start" ]
docker-compose で実行
HMR を使用する想定。
Dockerfile でtini
をインストールしておく。dist
とpackage.json
を compose からマウントすることでHMRを効かせる。
# ディレクトリにあるファイル
# 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" ]
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