はじめに
前回の記事で作成したDiscord botの基本構造をベースに、外部サーバーの起動・停止を制御できる機能を実装します。さらに、将来の機能拡張にも対応できる設計にしていきます。
tensordockAPIで作りますが、汎用的に作るので他のサービスでも簡単に応用できると思います。
tensorDockのチュートリアルはこちら->https://qiita.com/nani/items/033d3a2b1ad989a0db0c
対象読者
- Discord Command botの基本は理解しているが、より実践的な機能実装を目指すエンジニア
- Discord botを使って外部サーバーの制御を実現したい方
システムの概要
本記事では、以下の機能を実装していきます:
- 外部サーバーの制御(起動・停止)
- セレクターの導入
- interactioHandlerによるinteraction制御
- タイマーによる自動停止機能
完成図
完成リポジトリはこちら
├── commands/
│ ├── list.js
│ ├── start.js
│ ├── startServer.js
│ ├── stop.js
│ └── stopServer.js
├── interactions/
│ ├── command.js
│ └── stringSelectMenu.js
├── lib/
│ ├── tensorDockApi.js
│ └── timerManager.js
├── deploy-commands.js
├── .env
├── index.js
└── interactionHandler.js
APIをコマンドで叩こう
1. 外部APIモジュール作成
まずAPIを使用しやすい用にモジュールにしておきます。
libディレクトリの下にTensorDockApi.js
を作成します。
import axios from 'axios';
const api_base_url = 'https://marketplace.tensordock.com/api/v0';
const api_key = process.env.TENSOR_DOCK_API_KEY;
const api_token = process.env.TENSOR_DOCK_API_TOKEN;
class TensorDock {
constructor(config) {
this.config = config;
this.axiosInstance = axios.create({
baseURL: api_base_url,
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
});
}
// data は FormData オブジェクトを受け取る, params はクエリパラメータ,headersもセットできる
async request(endpoint, method = 'GET', data = null, params = {}) {
try {
const response = await this.axiosInstance({
method,
url: endpoint,
data,
params,
});
return response.data;
} catch (error) {
switch (error.response.status) {
case 404:
console.error(`API request failed: ${error.response.data.error}`);
break
case 500:
// 本来はエラーだが向こうのAPI設計がおかしいのでここでハンドリング
if (error.response.data.error === "Machine is stoppeddisassociated, therefore it cannot be stopped") {
return {success: true, error: "Machine is stoppeddisassociated, therefore it cannot be stopped", status: 'already'};
} else if (error.response.data.error === "Machine is running, therefore it cannot be started") {
return {success: true, error: "Machine is running, therefore it cannot be started", status: 'already'};
}
break;
default:
console.error(`API request failed: ${error.message}`);
break;
}
console.error(`API request failed: ${error.message}`);
// 具体的なエラー処理(例:リトライ、通知など)
throw error;
}
}
/**
* status: "success" or "false" or "already"
* @param serverId
* @returns {Promise<any|{success: boolean, error: string}|{success: boolean, error: string}|undefined>}
*/
async start(serverId) {
const params = { server: serverId };
const formData = await this.getFormData(params);
return this.request('/client/start/single', 'POST', formData, {});
}
async stop(serverId) {
// disassociate_resources: "true" // gpuを解放する
const params = { server: serverId, disassociate_resources: 'true' };
const formData = await this.getFormData(params);
return this.request('/client/stop/single', 'POST', formData, {});
}
/**
* id -> info のmapを返す
* @param params
* @returns {Promise<*>}
*/
async list(params = {}) {
const formData = await this.getFormData(params);
const res = await this.request('/client/list', 'POST', formData, {});
return res.virtualmachines;
}
async detail(serverId) {
const params = { server: serverId };
const formData = await this.getFormData(params);
return this.request('/client/get/single', 'POST', formData, {});
}
async getFormData(params) {
const formData = new FormData();
if (params) {
Object.keys(params).forEach(key => {
formData.append(key, params[key]);
});
}
// APIキーをヘッダーにセット
formData.append('api_key', api_key);
formData.append('api_token', api_token);
return formData;
}
}
export { TensorDock };
.envファイルにapi_keyとapi_tokenを追加
TENSOR_DOCK_API_KEY=YOUR-API-KEY
TENSOR_DOCK_API_TOKEN=YOUR-API-TOKEN
シンプルなAPIライブラリとして実装。
注意すべきポイントとしてはtensordock側のAPIが実装途中のせいなのか
start,stopともにalready start,stop状態のとき、status=200で返すべきところを500で返してる。自分でハンドリングしてあげないと頭がこんがらがるので自分のAPIモジュールを通して正常化されるように実装。
2. startコマンド実装
このapiモジュールを使ってidを取得するためのlistコマンドと、serverを起動するstartコマンドを実装してみましょう
commandsディレクトリの下にlist.js,start.jsを作成します。
import { SlashCommandBuilder } from 'discord.js';
import {TensorDock} from "../lib/tensorDockApi.js";
export const data = new SlashCommandBuilder()
.setName('list')
.setDescription('サーバーの一覧を表示します');
export async function execute(interaction) {
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ content: '処理中...', ephemeral: false }); // まず応答を返す
} else {
await interaction.reply({ content: '処理中...', ephemeral: false }); // まず応答を返す
}
const tensordock = new TensorDock();
const serverMap = await tensordock.list();
const serverIds = Object.keys(serverMap);
let text = '';
for (let i = 0; i < serverIds.length; i++) {
// とりあえずserverIdだけ表示
text += `${serverIds[i]}\n`;
}
await interaction.followUp({
content: `${text}`,
ephemeral: false,
});
}
import { SlashCommandBuilder } from 'discord.js';
import { TensorDock} from "../lib/tensorDockApi.js";
export const data = new SlashCommandBuilder()
.setName('start').addStringOption(option => option.setName('server_id').setDescription('server_id').setRequired(true))
.setDescription('サーバーを起動します。');
export async function execute(interaction) {
if (interaction.replied || interaction.deferred) {
interaction.followUp({ content: '処理中...', ephemeral: false }); // まず応答を返す
} else {
interaction.reply({ content: '処理中...', ephemeral: false }); // まず応答を返す
}
const serverId = interaction.options.getString('server_id');
const tensordock = new TensorDock();
const res = await tensordock.start(serverId);
if (res.success === false) {
await interaction.followUp({
content: 'サーバーの起動に失敗しました。'+ res.error,
ephemeral: false,
});
return;
}
if (res.status === true && res.status === 'already') {
await interaction.followUp({
content: 'サーバーは既に起動しています。',
ephemeral: false,
});
}
await interaction.followUp(`サーバーを起動しました。`, { ephemeral: false });
}
試しに使ってみましょう
- node deploy-commands
実行しコマンドをデプロイする。
- auth_urlを踏みサーバーに新botをいれる。(過去記事にautu_url取得方法書いてます。)
- コマンド/list
を実行し、idを取得
- コマンド/start {serverId}
を実行
こんな感じでできたら成功です。
セレクター実装
上記の方法だと起動にserverIdが必要なので、listコマンドを先にたたき、それをコピペする必要があります。
ちょっと煩わしいので、より柔軟なセレクター方式への移行を行います:
まずcommandsディレクトリにセレクターコンポーネントを表示するコマンド、startServer.jsを追加しましょう。
import { SlashCommandBuilder, ActionRowBuilder, StringSelectMenuBuilder } from 'discord.js';
import { TensorDock } from "../lib/tensorDockApi.js";
export const data = new SlashCommandBuilder()
.setName('start-server')
.setDescription('起動するサーバーを選択します。');
export async function execute(interaction) {
await interaction.reply({ content: '処理中...', ephemeral: false }); // まず応答を返す
const tensordock = new TensorDock();
const serverMap = await tensordock.list();
const serverIds = Object.keys(serverMap);
const options = serverIds.map((serverId) => {
if (serverMap[serverId].status === 'StoppedDisassociated') {
const label = serverMap[serverId].name + ': ' + serverMap[serverId].status;
return {
label: label,
value: serverId,
};
}
return null;
}).filter((option) => option !== null);
if (options.length === 0) {
await interaction.followUp({
content: '起動可能なサーバーがありません。',
ephemeral: false,
});
return;
}
const selectMenu = new StringSelectMenuBuilder()
.setCustomId('start-server')
.setPlaceholder('Select a server...')
.addOptions(options);
const row = new ActionRowBuilder().addComponents(selectMenu);
await interaction.followUp({
content: 'Please select a server:',
components: [row],
ephemeral: true, // メッセージをプライベートに
});
}
次にセレクターのinteractionを受け取るための処理をindex.jsに追加します。
// .......
// interactionがあったときの処理
client.on(Events.InteractionCreate, async interaction => {
// interactionの種類によって処理を分ける https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object-interaction-type
// コマンドのinteraction
if (interaction.type === 2) { // 2: APPLICATION_COMMAND
const command = interaction.client.commands.get(interaction.commandName);
try {
await command.execute(interaction);
} catch (error) {
console.error(error);
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ content: 'There was an error while executing this command!', ephemeral: true });
} else {
await interaction.reply({ content: 'There was an error while executing this command!', ephemeral: true });
}
}
}
// select menuのinteraction
if (interaction.type === 3) { // 3: MESSAGE_COMPONENT
if (interaction.customId === 'start-server') {
// 選択されたらcomponentを削除
await interaction.update({ components: [] });
const selectedServerId = interaction.values[0];
console.log(`Selected server: ${selectedServerId}`);
const startCommand = client.commands.get('start');
try {
await startCommand.execute(interaction, selectedServerId);
} catch (error) {
if (interaction.replied || interaction.deferred) {
await interaction.followUp({
content: `Error starting timer: ${error.message}`,
ephemeral: true,
});
} else {
await interaction.reply({
content: `Error starting timer: ${error.message}`,
ephemeral: true,
});
}
}
}
}
});
client.login(TOKEN);
startコマンドは自動で実行されるため、オプションで引数を渡せないので直接コマンドにserverIdを渡せるようにstart.jsも改良します
import { SlashCommandBuilder } from 'discord.js';
import { TensorDock} from "../lib/tensorDockApi.js";
export const data = new SlashCommandBuilder()
.setName('start')
// .addStringOption(option => option.setName('server_id').setDescription('server_id').setRequired(true)) 引数オプションを削除
.setDescription('サーバーを起動します。');
export async function execute(interaction, serverId=null) { // ※引数serverIdを追加
if (interaction.replied || interaction.deferred) {
interaction.followUp({ content: '処理中...', ephemeral: false }); // まず応答を返す
} else {
interaction.reply({ content: '処理中...', ephemeral: false }); // まず応答を返す
}
// const serverId = interaction.options.getString('server_id'); // オプションで渡されないので削除
// ...
これでまたコマンドを更新し、サーバーにbotを追加します。
そしてstartServerを叩くと
起動しているのでサーバーが表示されません。
一旦、tensrockのUIからserverを止めて再度実行
このようなコンポーネントが表示されれば成功です。サーバーnameを選択するとstartコマンドが実行されます。
ただこのままだとstopコマンドやextendコマンドで同じ用にセレクターを実装していくとindex.jsが肥大化するのでいい感じに処理を分割していきます。
コードの構造化
増加するイベントハンドリングに対応するため、以下の改善を行います:
1. interactionハンドラーの実装
discordのinteractionは公式documentによるとinteraction.type=2
ならAPPLICATION_COMMAND(コマンドアクション)、interaction.type===3
ならMESSAGE_COMPONENT(コンポーネントアクション)らしいです。
// ...
import {interactionHandler} from "./interactionHandler.js";
// ...
// interactionがあったときの処理
client.on(Events.InteractionCreate, async interaction => {
await interactionHandler(interaction, client);
});
import fs from "fs";
import path from "path";
const interactions = new Map();
// interactionsディレクトリからinteractionファイルを読み込む
const __dirname = import.meta.dirname // 現在のファイルがあるディレクトリ
const interactionFolderPath = path.join(__dirname, 'interactions'); // interactionsディレクトリのパス
const interactionFiles = fs.readdirSync(interactionFolderPath).filter(file => file.endsWith(".js"));
(async () => {
for (const file of interactionFiles) {
const filePath = path.join(interactionFolderPath, file);
console.log(filePath);
const interaction = await import(filePath);
if ('type' in interaction && 'execute' in interaction) {
interactions.set(interaction.type, interaction.execute);
} else {
console.log(`[WARNING] The interaction at ${filePath} is missing a required "type" or "execute" property.`);
}
}
})();
export async function interactionHandler(interaction, client) {
const handler = interactions.get(interaction.type);
if (!handler) {
return interaction.followUp({
content: `Interaction not found! ${interaction.type}`,
ephemeral: true,
});
}
try {
await handler(client, interaction);
} catch (error) {
console.error(error);
await interaction.reply({
content: 'An error occurred while executing the interaction.',
ephemeral: true,
});
}
}
2. モジュールの分割
commnadの処理とセレクトメニューの処理をそれぞれファイルで分割します。
// コマンドのinteraction
export const type = 2; // 2: APPLICATION_COMMAND
export async function execute(client, interaction) {
const command = client.commands.get(interaction.commandName);
try {
await command.execute(interaction);
} catch (error) {
console.error(error);
if (interaction.replied || interaction.deferred) {
await interaction.followUp({ content: 'There was an error while executing this command!', ephemeral: true });
} else {
await interaction.reply({ content: 'There was an error while executing this command!', ephemeral: true });
}
}
}
export const type = 3; // 3: MESSAGE_COMPONENT
export async function execute(client, interaction) {
console.log('stringSelectMenu executed: ', interaction.customId);
if (interaction.customId === 'start-server') {
// 選択されたらcomponentを削除
await interaction.update({ components: [] });
const selectedServerId = interaction.values[0];
console.log(`Selected server: ${selectedServerId}`);
const startCommand = client.commands.get('start');
try {
await startCommand.execute(interaction, selectedServerId);
} catch (error) {
if (interaction.replied || interaction.deferred) {
await interaction.followUp({
content: `Error starting timer: ${error.message}`,
ephemeral: true,
});
} else {
await interaction.reply({
content: `Error starting timer: ${error.message}`,
ephemeral: true,
});
}
}
}
}
これでindex.jsに存在していたinteraction.typeによる処理をinteractionsディレクトリの下に分割できました。
※ 本来ならstringSelectMenu.jsもcommand.jsと同じように、menuディレクトリを作成し、一つずつカプセル化するのが理想ですが、今回はstringSelectMenuで行う処理がほぼ同じになるので、if文のみで簡単に実装しました。
もしselectMenuを多種多様に実装したいならcommandコレクション(client.commands)と同じ用にmenuコレクションをindex.jsで作成し、stringSelectMenu.jsでハンドリングすると良いと思います。
start.js,startServer.jsと同様にしてstop.js,stopServer.js,それに対応するstringSelectMenu.jsの処理も追加しましょう。
安全機能の実装
GPUサーバーのコスト管理のため、以下の機能を追加します:
- タイマーモジュールの実装
class TimerManager {
// private static instance
static #instance = null;
// private constructor
constructor() {
// 2回目以降のnew TimerManager()を防ぐ
if (TimerManager.#instance) {
throw new Error('TimerManagerは直接インスタンス化できません。getInstance()を使用してください。');
}
this.timers = new Map();
this.callbacks = new Map();
TimerManager.#instance = this;
}
// public static getInstance method
static getInstance() {
if (!TimerManager.#instance) {
TimerManager.#instance = new TimerManager();
}
return TimerManager.#instance;
}
startTimer(serverId, callback, delay) {
if (this.timers.has(serverId)) {
throw new Error(`Timer for serverId ${serverId} is already running.`);
}
const timer = setTimeout(() => {
callback();
this.timers.delete(serverId);
this.callbacks.delete(serverId);
}, delay);
this.timers.set(serverId, timer);
this.callbacks.set(serverId, callback);
}
clearTimer(serverId) {
if (!this.timers.has(serverId)) {
throw new Error(`Timer for serverId ${serverId} is not running.`);
}
clearTimeout(this.timers.get(serverId));
this.timers.delete(serverId);
this.callbacks.delete(serverId);
}
}
// エクスポートする単一のインスタンス
export const timerManager = TimerManager.getInstance();
- start.jsにtimerを組み込みましょう
// ...
import { timerManager } from '../lib/timerManager.js';
// ...
// timerをセット
const callback = async () => {
await interaction.client.commands.get('stop').execute(interaction, serverId);
timerManager.clearTimer(serverId);
}
timerManager.startTimer(serverId, callback, 60*60*1000); // 1時間後に停止
await interaction.followUp(`サーバーを起動しました。`, { ephemeral: false });
試しに60*1000秒後に設定して止まる確認します。
/start-server
を実行
無事自動停止されました。
次回予告
次回は実際にサービスをサーバーに置いて、運用するまでの流れをやります。
具体的にはcomfyUIをサーバーに設置し、discordbotで起動したときにだけhttpsでそのURLに処理を投げられる仕組みを作ります。