🎮

ファイルをスラッシュコマンドで受け取るDiscordBotを作る

2022/03/13に公開

Discord BOT作りました

ボードゲームのCODE-NAMEをDiscord上で遊べるBOT「DISCODE-NAME」を作った。(まだ権限周りの設定を実際にプレイして動作確認してないので、完成したら招待リンク貼ります)
既にオンラインで遊べるサービスはあるんだけど、Discord上ですぐ遊べると便利かなと思ったので。

最初はチャットで単語を選ぶ形式にしようと思っていたのだが、今回のcode-nameはチャットベースで作ると以下の点がイマイチ。

  • テンポが悪くなる
  • 打ち間違いの可能性がある
  • チャットが長々と続くため、一度ゲームを実行するとそれまでのチャットが流れてしまう(これはプレイヤーの行動履歴が確認できると言うメリットでもあるが)

なので、今回はMessage Componentを使って、画像のようにボタンで単語を選択する形式にしてみた。

躓いたところ

作成に当たって情報が少なくてかなりハマった部分(主に記事のタイトルになっているスラッシュコマンドでファイルを受け取る部分)があるので、備忘録的に示しておく。もし同じようなことをしようとして困った方がいたら参考にしていただければ。

ラッパーライブラリ

DiscordのBOTを作るときはラッパーライブラリを使って作成する方法が主流らしい。
いろいろな言語のライブラリがあるが、軽く調べた感じだと、おおむね「discord.js」(node.js)か「discord.py」(python)の二つのライブラリが主流っぽい。とりあえず最初は、どちらかと言えば慣れているpythonの方を選択した。

ところが、pipでdiscord.pyを落とすと、ドキュメントには存在するメッセージコンポーネント関連のモジュールが見当たらない。

どう言うことやと思って調べてみると、どうやら、DiscordのBOTは現在割と過渡期にあるらしく、いろいろ大きな仕様変更があって、discord.pyは作者が開発を中止してるみたい。
MessageComponentはその大きな仕様変更の結果使えるようになった比較的新しい機能なので、どうやら現在pipでインストールできるdiscord.pyでは対応していないようだ。

一応、後継のライブラリは開発されており、githubからinstallできるらしいのだが、ドキュメントを見る限り、componentの操作もあまり直観的じゃなかったり(一度作成したコンポーネントを再レンダリングする方法がいまいちわからなかった)、情報もdiscord.jsの方が充実している感じがしたので、discord.pyはやめて、discord.jsの方で作ることにした。

ファイルをオプションとして持つスラッシュコマンドの登録

数年ぶりにnode.js本体を触って、非同期処理の仕様が思い出せなくて若干戸惑ったり、ESModuleが使えるようになってることに感動したりしながら、code-nameのゲーム部分を実装したら、いよいよDiscord上からゲームを操作できるようにしていく。

BOTに対して何らかの指示を与えたい場合、メッセージ内容をBOTに監視させて特定のキーワード(コマンド)を含む場合に何らかの処理を行うように実装していく方法と、予めスラッシュコマンドというものを登録しておいて、そのコマンドに対応する処理を実装していく方法がある。

前者の方が柔軟に動かせそうだが、こちらの記事によると、100を超えるサーバにBOTが所属している場合は、前者の方法は使用できないようだ。
特段100サーバ以上で動かすような予定もないのだが、おそらくこれからはスラッシュコマンドが主流になってくるだろうし、自分のやりたいことはスラッシュコマンドだけで十分実装可能っぽかったので、とりあえずスラッシュコマンドの登録を行うことにした。

今回登録したコマンドは以下の3つ

  1. prepareコマンド ゲームの準備を行うコマンド。実行された場合、チーム分けのためのボタンを表示する。
  2. startコマンド ゲームの開始を行うコマンド。prepareコマンドでプレイヤーをチーム分けした後に実行することで、ゲームが開始される。引数として単語をカンマで区切ったテキストファイルを受け取ることができ、受け取ったファイルの単語を使用してゲームをプレイすることができる。
  3. endコマンド ゲームを終了する。

discord.jsのドキュメントを読むとわかるけど、とりあえずスラッシュコマンドを登録したいならSlashCommandBuilderが便利。
コマンド名やオプションのタイプを指定してあげれば、後はよしなにリクエストを投げてくれる。

const commands = [
    new SlashCommandBuilder().setName('コマンドの名前').setDescription('コマンドの節女い')
    // オプション(引数)を指定したい場合、そのオプションの種類に対応するaddXXXOptionで追加する。
    .addStringOption(option =>
		option.setName('input')
			.setDescription('The input to echo back')
			.setRequired(true));]
    .map(command => {
        return command.toJSON()});
const rest = new REST({ version: '9' }).setToken(token);
await rest.put(Routes.applicationGuildCommands(clientId, guildId), { body: commands })

これだけでOK。

…なのだが、、今回自分が登録したいstartコマンドのように、ファイルを引数として受け取る(attachオプション)を持つコマンドの登録にはどうやらまだ対応していないらしい。(プルリクは既にあるっぽいんだけど

仕方ないのでDiscord API公式のドキュメントを読みつつ、startコマンドの登録だけは普通に生のリクエストを投げるような形式にした。

import { SlashCommandBuilder } from '@discordjs/builders';
import { REST } from '@discordjs/rest';
import { Routes } from 'discord-api-types/v9';
import request from 'request';
import dotenv from 'dotenv';
dotenv.config();
const [clientId, guildId, token] = [process.env.CLIENT_ID, process.env.GUILD_ID, process.env.TOKEN];
/**preparとendコマンドはSlashCommandBuilderで*/
const commands = [
    new SlashCommandBuilder().setName('prepare').setDescription('ゲーム開始前に参加者と役職を決定します')
    .addStringOption(o =>o.setName('input')
			.setDescription('The input to echo back')
			.setRequired(false)),
    new SlashCommandBuilder().setName('restart').setDescription('同じ役職で再度ゲームを行います'),
    new SlashCommandBuilder().setName('end').setDescription('現在のゲームを終了します'),

]
    .map(command => {
        return command.toJSON()});

const rest = new REST({ version: '9' }).setToken(token);
// 特定のサーバにのみコマンドを反映させたい場合はサーバのid(guildId)をエンドポイントに含める(動作確認に便利)
// await rest.put(Routes.applicationGuildCommands(clientId, guildId), { body: commands })
await rest.put(Routes.applicationGuildCommands(clientId), { body: commands })
.then(() => console.log('Successfully registered application commands.'))
.catch(e =>console.log(e.requestBody.json));

/**startだけ生で*/
let  json = {
    "name": "start",
    "type": 1,
    "description": "ゲームを開始します",
    "options": [
        {
            "name": "words_file",
            "description": "独自のワードファイルを使用したい場合はファイルを送信してください",
	    // ファイルを受け取りたい場合はtype:11を指定する
            "type": 11,
            "required": false
        }
    ]
}
// 特定のサーバにのみコマンドを反映させたい場合はサーバのid(guildId)をエンドポイントに含める(動作確認に便利)
// let url = `https://discord.com/api/v8/applications/${clientId}/guilds/${guildId}/commands`
let url = `https://discord.com/api/v8/applications/${clientId}/commands`
let headers = {
    "Authorization": `Bot ${token}`
}

let r = request.post({url:url, headers:headers, json:json},(e,r,b) => console.log(b));

ファイルの受け取り

次に、コマンドを受け取って返信を返すサーバを作成する。
これもdiscord.jsを使うと楽々で、以下のようにclient.on();でinteractionCreateのイベントリスナーを登録してclient.login();するだけで実装できる。

client.on('interactionCreate', async interaction => {
	if (!interaction.isCommand()) return;
	if (interaction.commandName === 'ping') {
		// パラメータinteractionのreplyメソッドを呼び出すことで、botがサーバ上で発言を行う
		interaction.reply('pong')
	}
};
client.login(token);

スラッシュコマンドが引数をとる場合は、interactionのoptionsプロパティが持つgetXXXを使用することで取得できる。

interaction.options.getString('input');
interaction.options.getBoolean('boolean')
...

が、例によってこのgetXXXにattachが存在しない。公式ドキュメントを調べて、どうやらdiscordから投げられるinteraction.data.resolvedの中にattachmentの情報があるらしいというところまではわかったのだが、client.on()で受け取るリクエスト情報はかなり抽象化されてるらしく、生データを取り出すようなオプションはないっぽい。
苦心していろいろ調べると、生のリクエスト情報はどうやらclient.wsで取得できるwebSocketManagerを使用すると取得できるっぽい(参考)
そこで、以下のようにすることでファイルの取得を実現することができた。

client.on('ready', () => {
	console.log('Ready!');
	client.ws.on('INTERACTION_CREATE', async interaction => {
		let attachments = interaction.data.resolved?.attachments;
		if (interaction.data.name === 'start') {
			let gameManager = gameManagerMap.get(interaction.channel_id);
			if (gameManager && attachments) {
				try {
				//attachmentsにアップデートされたファイルのURLが入っている
					let url = attachments[interaction.data.options[0].value].url;
					if (url) {
					// getでファイルを取得する
						axios
							.get(url)
							.then(res => {
								// ゲーム部分に単語ファイルを渡す処理
								gameManager.createWords(res.data);
							})
							.catch(error => {
								console.error(error)
							})
					}
				} catch (error) {
					interaction.reply('渡されたファイルの形式が不正です。/nカンマ区切りかどうか、単語数が足りているかなどを確認してください')
				}
			} else if (gameManager && !attachments) {
			// ゲーム部分に単語ファイルを渡す処理
				gameManager.createWords();
			}
		}
	});
}); 
 

ここが一番情報が少なくてハマった。

Discussion