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

## はじめに

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

## 対象読者

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

## システムの概要

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

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

## 完成図
完成リポジトリは[こちら](https://github.com/nanyanen87/tensor-bot-v2/tree/feature/version_2)

```
├── 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ライブラリとして実装。<br>注意すべきポイントとしてはtensordock側のAPIが実装途中のせいなのか<br> 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をいれる。([過去記事](https://qiita.com/nani/items/e3ed6d32d59290cf495d)にautu_url取得方法書いてます。)
    - コマンド```/list```を実行し、idを取得
    - コマンド```/start {serverId}```を実行


![スクリーンショット 2024-12-18 215738.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3192330/c6e93e81-87b5-0d66-fe74-cc9633f21323.png)
![スクリーンショット 2024-12-18 215930.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3192330/a3bb887f-5936-497d-9682-3269e457196d.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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3192330/da584086-ae0f-3c05-2594-89b55d1585d2.png)

起動しているのでサーバーが表示されません。<br>一旦、tensrockのUIからserverを止めて再度実行
![スクリーンショット 2024-12-18 221030.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3192330/1ea22a86-acbc-1fbd-5ab4-71e5969a0150.png)

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

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

## コードの構造化

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

### 1. interactionハンドラーの実装
discordのinteractionは[公式document](https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object-interaction-type)によると```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文のみで簡単に実装しました。<br>もしselectMenuを多種多様に実装したいならcommandコレクション（client.commands）と同じ用にmenuコレクションをindex.jsで作成し、stringSelectMenu.jsでハンドリングすると良いと思います。

start.js,startServer.jsと同様にしてstop.js,stopServer.js,それに対応するstringSelectMenu.jsの処理も追加しましょう。


![スクリーンショット 2024-12-18 224956.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3192330/958716b5-891b-07af-4f17-bcdc3eea1ac2.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();
```
2. 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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3192330/9179f5c8-da03-4772-c520-b16a672fee5f.png)

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

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