0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【TensorDock】Discord Botで外部サーバーを制御する仕組みを作る

Last updated at Posted at 2024-12-19

はじめに

前回の記事で作成したDiscord botの基本構造をベースに、外部サーバーの起動・停止を制御できる機能を実装します。さらに、将来の機能拡張にも対応できる設計にしていきます。
tensordockAPIで作りますが、汎用的に作るので他のサービスでも簡単に応用できると思います。
tensorDockのチュートリアルはこちら->https://qiita.com/nani/items/033d3a2b1ad989a0db0c

対象読者

  • Discord Command botの基本は理解しているが、より実践的な機能実装を目指すエンジニア
  • Discord botを使って外部サーバーの制御を実現したい方

システムの概要

本記事では、以下の機能を実装していきます:

  1. 外部サーバーの制御(起動・停止)
  2. セレクターの導入
  3. interactioHandlerによるinteraction制御
  4. タイマーによる自動停止機能

完成図

完成リポジトリはこちら

├── 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を作成します。

TensroDockApi.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を追加

.env
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を作成します。

list.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,
    });
}
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) {
    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}を実行

スクリーンショット 2024-12-18 215738.png
スクリーンショット 2024-12-18 215930.png

こんな感じでできたら成功です。

セレクター実装

上記の方法だと起動にserverIdが必要なので、listコマンドを先にたたき、それをコピペする必要があります。
ちょっと煩わしいので、より柔軟なセレクター方式への移行を行います:

まずcommandsディレクトリにセレクターコンポーネントを表示するコマンド、startServer.jsを追加しましょう。

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に追加します。

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も改良します

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を叩くと
スクリーンショット 2024-12-18 220719.png

起動しているのでサーバーが表示されません。
一旦、tensrockのUIからserverを止めて再度実行
スクリーンショット 2024-12-18 221030.png

このようなコンポーネントが表示されれば成功です。サーバーnameを選択するとstartコマンドが実行されます。

ただこのままだとstopコマンドやextendコマンドで同じ用にセレクターを実装していくとindex.jsが肥大化するのでいい感じに処理を分割していきます。

コードの構造化

増加するイベントハンドリングに対応するため、以下の改善を行います:

1. interactionハンドラーの実装

discordのinteractionは公式documentによるとinteraction.type=2ならAPPLICATION_COMMAND(コマンドアクション)、interaction.type===3ならMESSAGE_COMPONENT(コンポーネントアクション)らしいです。

index.js
// ...
import {interactionHandler} from "./interactionHandler.js";

// ...

// interactionがあったときの処理
client.on(Events.InteractionCreate, async interaction => {
    await interactionHandler(interaction, client);
});


interactionHandler.js
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の処理とセレクトメニューの処理をそれぞれファイルで分割します。

interactions/command.js
// コマンドの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 });
            }
        }
}
interactions/stringSelectMenu.js
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の処理も追加しましょう。

スクリーンショット 2024-12-18 224956.png

安全機能の実装

GPUサーバーのコスト管理のため、以下の機能を追加します:

  1. タイマーモジュールの実装
timerManager.js
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();
  1. start.jsにtimerを組み込みましょう
start.js
// ...
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を実行

スクリーンショット 2024-12-18 230445.png

無事自動停止されました。

次回予告

次回は実際にサービスをサーバーに置いて、運用するまでの流れをやります。
具体的にはcomfyUIをサーバーに設置し、discordbotで起動したときにだけhttpsでそのURLに処理を投げられる仕組みを作ります。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?