こんにちは。
以前、身内向けに麻雀大会支援ツールとしてDiscordBotを作成したのですが、新たにいくつか試したいアイデアが生まれたので改修をしてみました。
↓以前の記事↓
※本記事にコードの大部分は記載しております。経緯等が気になる方は以前の記事をご覧ください。
環境
- Discord.js v13.17.1
- GoogleAppsScript
改修内容
今回は新たな機能として、集計の自動化 を行いたいと考えました。
前回は実装のイメージが付かなかったため実現しませんでしたが、今回イメージ感が掴めたことにより実装に至りました。
前提として参加者自身に点数入力をしてもらう方法を取るため、「わかりやすさ」を重視する必要がありました。具体的な方法は以下になります。
- botからのメッセージに結果報告用のボタンを追加
- ボタンが押下されたらウィンドウを生成し、フォームに入力してもらう
- 送信ボタンで送信
テキストメッセージやGoogleフォームを使用する方法も考えたのですが、前者は特定のキーワード等を用いて入力形式を指定する必要があり、後者はフォームへアクセスする手間があることから参加者の負担が大きいため採用を見送っていました。
また、以前はテキストメッセージで行っていたbot操作をギルドコマンドによって実現しました。
今回もbotは以下記事をベースに作成させていただいております。
作成したスクリプト
まずはGlitch側で作成したスクリプトになります。(長いので閉じています。)
server
const http = require("http");
const querystring = require("querystring");
const discord = require("discord.js");
const client = new discord.Client({ intents: ["GUILDS", "GUILD_MESSAGES"] })
const {Modal,TextInputComponent,TextInputStyle,MessageActionRow,MessageEmbed} = require("discord.js");
var n;//ラウンドを記憶する変数
var postData;
var responseData;
var responseRankData;
var responseResult;
var inputResult;//得点送信結果
const MatchFlg = false;
//ボタンを作成
const buttonA = createButton("A卓","sendButtonA","PRIMARY");
const buttonB = createButton("B卓","sendButtonB","PRIMARY");
const buttonC = createButton("C卓","sendButtonC","PRIMARY");
const buttonD = createButton("D卓","sendButtonD","PRIMARY");
const buttonRow = new MessageActionRow().addComponents([buttonA, buttonB, buttonC, buttonD]);
const axiosBase = require("axios");
const url = "GASのURL"; // gasのドメイン以降のurl
const data = {"key" : "value"}; // 送信するデータ
const axios = axiosBase.create({
baseURL: "https://script.google.com",
headers: {
"Content-Type": "application/json",
"X-Requested-With": "XMLHttpRequest",
},
responseType: "json",
});
http
.createServer(function (req, res) {
if (req.method == "POST") {
var data = "";
req.on("data", function (chunk) {
data += chunk;
});
req.on("end", function () {
if (!data) {
console.log("No post data");
res.end();
return;
}
var dataObject = querystring.parse(data);
console.log("post:" + dataObject.type);
if (dataObject.type == "wake") {
console.log("Woke up in post");
res.end();
return;
}
res.end();
});
} else if (req.method == "GET") {
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("Discord Bot is active now\n");
}
})
.listen(3000);
client.on("ready", (message) => {
console.log("Bot準備完了");
});
//コマンド設定
client.once("ready", async () => {
const cmdData = [{
name: "championship_start",
description: "新規大会作成",
},{
name: "next_matching",
description: "次対局開始",
},{
name: "final_matching",
description: "順位決定戦開始",
},{
name: "get_leaderboard",
description: "順位表取得",
}];
const command = await client.application?.commands.set(cmdData,'サーバーのギルドID');
console.log("Ready!");
});
//インタラクションへの反応
client.on("interactionCreate", async (interaction) => {
//大会開始コマンド
if (interaction.commandName === 'championship_start') {
MatchFlg == true;
n = 1;
postData = n;
axios.post(url, postData)
.then(async function (response) {
responseData = response.data; // 受け取ったデータを格納
console.log(response["data"]);
interaction.reply(
{embeds: [{
title: "Round1",
color: 16729344,
fields: [
{
name: "A卓",
value: `${response["data"][0]},${response["data"][1]},${response["data"][2]},${response["data"][3]}`
},
{
name: "B卓",
value: `${response["data"][4]},${response["data"][5]},${response["data"][6]},${response["data"][7]}`
},
{
name: "C卓",
value: `${response["data"][8]},${response["data"][9]},${response["data"][10]},${response["data"][11]}`
},
{
name: "D卓",
value: `${response["data"][12]},${response["data"][13]},${response["data"][14]},${response["data"][15]}`
}
]
}],
components: [buttonRow]
});
})
.catch(function (error) {
console.log("ERROR!! occurred in Backend.");
console.log(error);
}
)
}
//次対局移行コマンド
if (interaction.commandName === 'next_matching') {
postData = n + 1;
axios.post(url, postData)
.then(async function (response) {
responseData = response.data; // 受け取ったデータ一覧(object)
console.log(response["data"]);
if(response["data"] == "err")
{
let text = "入力されていないセルがあります:r"+ n;
interaction.reply(text);
}
else if(n >= 1 && n <= 4)
{
interaction.reply(
{embeds: [{
title: "Round" + postData,
color: 16729344,
fields: [
{
name: "A卓",
value: `${response["data"][0]},${response["data"][1]},${response["data"][2]},${response["data"][3]}`
},
{
name: "B卓",
value: `${response["data"][4]},${response["data"][5]},${response["data"][6]},${response["data"][7]}`
},
{
name: "C卓",
value: `${response["data"][8]},${response["data"][9]},${response["data"][10]},${response["data"][11]}`
},
{
name: "D卓",
value: `${response["data"][12]},${response["data"][13]},${response["data"][14]},${response["data"][15]}`
}
]
}],
components: [buttonRow]
});
n = n + 1
}else{
console.log("err");
console.log(n);
}})
.catch(function (error) {
console.log("ERROR!! occurred in Backend.");
console.log(error);
})}
//順位決定戦開始コマンド
if (interaction.commandName === 'final_matching') {
postData = 99;
axios.post(url, postData)
.then(async function (response) {
responseData = response.data;
console.log(response["data"]);
interaction.reply(
{embeds:[{
title: "順位決定戦",
color: 16729344,
fields: [
{
name: "A卓",
value: `${response["data"][0]},${response["data"][1]},${response["data"][2]},${response["data"][3]}`
},
{
name: "B卓",
value: `${response["data"][4]},${response["data"][5]},${response["data"][6]},${response["data"][7]}`
},
{
name: "C卓",
value: `${response["data"][8]},${response["data"][9]},${response["data"][10]},${response["data"][11]}`
},
{
name: "D卓",
value: `${response["data"][12]},${response["data"][13]},${response["data"][14]},${response["data"][15]}`
}
]
}]})
.catch(function (error) {
console.log("ERROR!! occurred in Backend.");
console.log(error);
})}
)}
//順位表取得コマンド
if (interaction.commandName === 'get_leaderboard') {
postData = 99;
axios.post(url, postData)
.then(async function (response) {
responseRankData = response.data;
console.log(response["data"]);
interaction.reply(
{embeds: [{
title: "順位表",
color: 16729344,
description: `**1位:${response["data"][0]}\n2位:${response["data"][1]}\n3位:${response["data"][2]}\n4位:${response["data"][3]}\n5位:${response["data"][4]}\n6位:${response["data"][5]}\n7位:${response["data"][6]}\n8位:${response["data"][7]}\n9位:${response["data"][8]}\n10位:${response["data"][9]}\n11位:${response["data"][10]}\n12位:${response["data"][11]}\n13位:${response["data"][12]}\n14位:${response["data"][13]}\n15位:${response["data"][14]}\n16位:${response["data"][15]}**`
}]}
)
})
};
//ModalWindowの入力値を取得
if(interaction.isModalSubmit()){
const p1 = interaction.fields.getTextInputValue('inputFirst');
const p2 = interaction.fields.getTextInputValue('inputSecond');
const p3 = interaction.fields.getTextInputValue('inputThird');
const p4 = interaction.fields.getTextInputValue('inputFourth');
//得点を配列に格納してモジュールに渡す
const pointList = [[p1],[p2],[p3],[p4]];
inputResult = sendData(pointList,interaction.customId,n);
await interaction.reply("データの送信を受け付けました")
}
//ボタンが押された時の挙動
if(interaction.isButton()){
console.log("ボタンが押下されました");
if(interaction.customId == "sendButtonA"){
const modal = createModal("pointsA", "A卓の結果を送信",getIndex(responseData,0),getIndex(responseData,1),getIndex(responseData,2),getIndex(responseData,3));
await interaction.showModal(modal);
}
if(interaction.customId == "sendButtonB"){
const modal = createModal("pointsB", "B卓の結果を送信",getIndex(responseData,4),getIndex(responseData,5),getIndex(responseData,6),getIndex(responseData,7));
await interaction.showModal(modal);
}
if(interaction.customId == "sendButtonC"){
const modal = createModal("pointsC", "C卓の結果を送信",getIndex(responseData,8),getIndex(responseData,9),getIndex(responseData,10),getIndex(responseData,11));
await interaction.showModal(modal);
}
if(interaction.customId == "sendButtonD"){
const modal = createModal("pointsD", "D卓の結果を送信",getIndex(responseData,12),getIndex(responseData,13),getIndex(responseData,14),getIndex(responseData,15));
await interaction.showModal(modal);
}
}
});
client.on("messageCreate", (message) => {
if (message.author.id == client.user.id) {
return; //botの発言に反応しないようにする
}
if(message.mentions.has(client.user)){
axios.post(url , JSON.stringify(message.content))
console.log(JSON.stringify(message.content));
return;
}
return;
});
//トークン未設定
if (process.env.DISCORD_BOT_TOKEN == undefined) {
console.log("DISCORD_BOT_TOKENが設定されていません。");
process.exit(0);
}
client.login(process.env.DISCORD_BOT_TOKEN);
/*未使用のモジュール
function sendReply(message, text) {
message
.reply(text)
.then(console.log("リプライ送信: " + text))
.catch(console.error);
}
function sendMsg(channelId, text, option = {}) {
client.channels
.get(channelId)
.send(text, option)
.then(console.log("メッセージ送信: " + text + JSON.stringify(option)))
.catch(console.error);
};*/
//配列のインデックス番号を指定しString型で返すモジュール
function getIndex(array,index){
const getResult = array[index].toString();
return getResult;
}
//ボタンを作成するモジュール
function createButton(label,customId,style){
const makingButton = new discord.MessageButton()
.setStyle(style)
.setLabel(label)
.setCustomId(customId);
return makingButton;
}
//ModalWindowを作成するモジュール
function createModal(customId,title,memberA,memberB,memberC,memberD){
const makingModal = new Modal()
.setCustomId(customId)
.setTitle(title);
const pointInputFirst = new TextInputComponent()
.setCustomId("inputFirst")
.setLabel(memberA)
.setStyle("SHORT");
const pointInputSecond = new TextInputComponent()
.setCustomId("inputSecond")
.setLabel(memberB)
.setStyle("SHORT");
const pointInputThird = new TextInputComponent()
.setCustomId("inputThird")
.setLabel(memberC)
.setStyle("SHORT");
const pointInputFourth = new TextInputComponent()
.setCustomId("inputFourth")
.setLabel(memberD)
.setStyle("SHORT");
makingModal.addComponents(new MessageActionRow().addComponents(pointInputFirst));
makingModal.addComponents(new MessageActionRow().addComponents(pointInputSecond));
makingModal.addComponents(new MessageActionRow().addComponents(pointInputThird));
makingModal.addComponents(new MessageActionRow().addComponents(pointInputFourth));
return makingModal;
}
//ModalWindowの入力値を送信するモジュール
function sendData(pointList,customId,n){
postData = [100,pointList, customId,n];
axios.post(url, postData)
.then(async function (response) {
responseResult = response.data; // 受け取ったデータを格納
console.log(response["data"]);
});
return responseResult;
}
それでは、今回改修した箇所を中心に簡単に解説していきます。
//コマンド設定
client.once("ready", async () => {
const cmdData = [{
name: "championship_start",
description: "新規大会作成",
},{
name: "next_matching",
description: "次対局開始",
},{
name: "final_matching",
description: "順位決定戦開始",
},{
name: "get_leaderboard",
description: "順位表取得",
}];
const command = await client.application?.commands.set(cmdData,'サーバーのギルドID');
console.log("Ready!");
});
ここではコマンドの定義をしています。コマンド名と説明を配列に列挙する形です。
Discord側のメッセージ欄に/を入力するとこのように候補が表示されます。
//インタラクションへの反応
client.on("interactionCreate", async (interaction) => {
//大会開始コマンド
if (interaction.commandName === 'championship_start') {
MatchFlg == true;
n = 1;
postData = n;
axios.post(url, postData)
.then(async function (response) {
responseData = response.data; // 受け取ったデータを格納
console.log(response["data"]);
interaction.reply(
{embeds: [{
title: "Round1",
color: 16729344,
fields: [
{
name: "A卓",
value: `${response["data"][0]},${response["data"][1]},${response["data"][2]},${response["data"][3]}`
},
{
name: "B卓",
value: `${response["data"][4]},${response["data"][5]},${response["data"][6]},${response["data"][7]}`
},
{
name: "C卓",
value: `${response["data"][8]},${response["data"][9]},${response["data"][10]},${response["data"][11]}`
},
{
name: "D卓",
value: `${response["data"][12]},${response["data"][13]},${response["data"][14]},${response["data"][15]}`
}
]
}],
components: [buttonRow]
});
})
.catch(function (error) {
console.log("ERROR!! occurred in Backend.");
console.log(error);
}
)
}
//次対局移行コマンド
if (interaction.commandName === 'next_matching') {
postData = n + 1;
axios.post(url, postData)
.then(async function (response) {
responseData = response.data; // 受け取ったデータ一覧(object)
console.log(response["data"]);
if(response["data"] == "err")
{
let text = "入力されていないセルがあります:r"+ n;
interaction.reply(text);
}
else if(n >= 1 && n <= 4)
{
interaction.reply(
{embeds: [{
title: "Round" + postData,
color: 16729344,
fields: [
{
name: "A卓",
value: `${response["data"][0]},${response["data"][1]},${response["data"][2]},${response["data"][3]}`
},
{
name: "B卓",
value: `${response["data"][4]},${response["data"][5]},${response["data"][6]},${response["data"][7]}`
},
{
name: "C卓",
value: `${response["data"][8]},${response["data"][9]},${response["data"][10]},${response["data"][11]}`
},
{
name: "D卓",
value: `${response["data"][12]},${response["data"][13]},${response["data"][14]},${response["data"][15]}`
}
]
}],
components: [buttonRow]
});
n = n + 1
}else{
console.log("err");
console.log(n);
}})
.catch(function (error) {
console.log("ERROR!! occurred in Backend.");
console.log(error);
})}
//順位決定戦開始コマンド
if (interaction.commandName === 'final_matching') {
postData = 99;
axios.post(url, postData)
.then(async function (response) {
responseData = response.data;
console.log(response["data"]);
interaction.reply(
{embeds:[{
title: "順位決定戦",
color: 16729344,
fields: [
{
name: "A卓",
value: `${response["data"][0]},${response["data"][1]},${response["data"][2]},${response["data"][3]}`
},
{
name: "B卓",
value: `${response["data"][4]},${response["data"][5]},${response["data"][6]},${response["data"][7]}`
},
{
name: "C卓",
value: `${response["data"][8]},${response["data"][9]},${response["data"][10]},${response["data"][11]}`
},
{
name: "D卓",
value: `${response["data"][12]},${response["data"][13]},${response["data"][14]},${response["data"][15]}`
}
]
}]})
.catch(function (error) {
console.log("ERROR!! occurred in Backend.");
console.log(error);
})}
)}
//順位表取得コマンド
if (interaction.commandName === 'get_leaderboard') {
postData = 99;
axios.post(url, postData)
.then(async function (response) {
responseRankData = response.data;
console.log(response["data"]);
interaction.reply(
{embeds: [{
title: "順位表",
color: 16729344,
description: `**1位:${response["data"][0]}\n2位:${response["data"][1]}\n3位:${response["data"][2]}\n4位:${response["data"][3]}\n5位:${response["data"][4]}\n6位:${response["data"][5]}\n7位:${response["data"][6]}\n8位:${response["data"][7]}\n9位:${response["data"][8]}\n10位:${response["data"][9]}\n11位:${response["data"][10]}\n12位:${response["data"][11]}\n13位:${response["data"][12]}\n14位:${response["data"][13]}\n15位:${response["data"][14]}\n16位:${response["data"][15]}**`
}]}
)
})
};
--------------------------中略--------------------------
});
今回実装を行った、「ギルドコマンド」「ボタン」「モーダルウィンドウ」はすべてインタラクションで動作を受け取ることになります。
ここでは各コマンドの処理内容を記述しています。基本的な機能は前回から変わっていませんが、/championship_startコマンド、/next_matchingコマンドのテキストにはコンストラクタで生成したボタン配列を新たに追加しています。
↑botからの返答イメージ
まずはコンストラクタでボタンを定義。4つセットで使用するので配列に格納しておきます。
//ボタンを作成
const buttonA = createButton("A卓","sendButtonA","PRIMARY");
const buttonB = createButton("B卓","sendButtonB","PRIMARY");
const buttonC = createButton("C卓","sendButtonC","PRIMARY");
const buttonD = createButton("D卓","sendButtonD","PRIMARY");
const buttonRow = new MessageActionRow().addComponents([buttonA, buttonB, buttonC, buttonD]);
//ボタンを作成するモジュール
function createButton(label,customId,style){
const makingButton = new discord.MessageButton()
.setStyle(style)
.setLabel(label)
.setCustomId(customId);
return makingButton;
}
//インタラクションへの反応
client.on("interactionCreate", async (interaction) => {
--------------------------中略--------------------------
//ボタンが押された時の挙動
if(interaction.isButton()){
console.log("ボタンが押下されました");
if(interaction.customId == "sendButtonA"){
const modal = createModal("pointsA", "A卓の結果を送信",getIndex(responseData,0),getIndex(responseData,1),getIndex(responseData,2),getIndex(responseData,3));
await interaction.showModal(modal);
}
if(interaction.customId == "sendButtonB"){
const modal = createModal("pointsB", "B卓の結果を送信",getIndex(responseData,4),getIndex(responseData,5),getIndex(responseData,6),getIndex(responseData,7));
await interaction.showModal(modal);
}
if(interaction.customId == "sendButtonC"){
const modal = createModal("pointsC", "C卓の結果を送信",getIndex(responseData,8),getIndex(responseData,9),getIndex(responseData,10),getIndex(responseData,11));
await interaction.showModal(modal);
}
if(interaction.customId == "sendButtonD"){
const modal = createModal("pointsD", "D卓の結果を送信",getIndex(responseData,12),getIndex(responseData,13),getIndex(responseData,14),getIndex(responseData,15));
await interaction.showModal(modal);
}
}
});
//配列のインデックス番号を指定しString型で返すモジュール
function getIndex(array,index){
const getResult = array[index].toString();
return getResult;
}
interaction.isButtonでボタンの押下を検知し、ボタンに設定してあるcustomIdによって押下されたボタンの種類を判別しています。
判別したデータをもとに後述するModalWindowを生成しているのですが、コマンドのresponseを使用する必要がありresponseを配列として与えるとうまくいかなかったため配列のインデックス番号を指定して要素を取り出すモジュールを使用しています。
//ModalWindowを作成するモジュール
function createModal(customId,title,memberA,memberB,memberC,memberD){
const makingModal = new Modal()
.setCustomId(customId)
.setTitle(title);
const pointInputFirst = new TextInputComponent()
.setCustomId("inputFirst")
.setLabel(memberA)
.setStyle("SHORT");
const pointInputSecond = new TextInputComponent()
.setCustomId("inputSecond")
.setLabel(memberB)
.setStyle("SHORT");
const pointInputThird = new TextInputComponent()
.setCustomId("inputThird")
.setLabel(memberC)
.setStyle("SHORT");
const pointInputFourth = new TextInputComponent()
.setCustomId("inputFourth")
.setLabel(memberD)
.setStyle("SHORT");
makingModal.addComponents(new MessageActionRow().addComponents(pointInputFirst));
makingModal.addComponents(new MessageActionRow().addComponents(pointInputSecond));
makingModal.addComponents(new MessageActionRow().addComponents(pointInputThird));
makingModal.addComponents(new MessageActionRow().addComponents(pointInputFourth));
return makingModal;
}
各入力欄に得点を数値で入力してもらい、インタラクションで入力値を受け取りGASにpostしています。
//インタラクションへの反応
client.on("interactionCreate", async (interaction) => {
--------------------------中略--------------------------
//ModalWindowの入力値を取得
if(interaction.isModalSubmit()){
const p1 = interaction.fields.getTextInputValue('inputFirst');
const p2 = interaction.fields.getTextInputValue('inputSecond');
const p3 = interaction.fields.getTextInputValue('inputThird');
const p4 = interaction.fields.getTextInputValue('inputFourth');
//得点を配列に格納してモジュールに渡す
const pointList = [[p1],[p2],[p3],[p4]];
inputResult = sendData(pointList,interaction.customId,n);
await interaction.reply("データの送信を受け付けました")
}
--------------------------中略--------------------------
});
//ModalWindowの入力値を送信するモジュール
function sendData(pointList,customId,n){
postData = [100,pointList, customId,n];
axios.post(url, postData)
.then(async function (response) {
responseResult = response.data; // 受け取ったデータを格納
console.log(response["data"]);
});
return responseResult;
}
Glitchで改修した機能は以上になります。続いてGAS。
こちらでは主にModalWindowからの送信データを受け取り、スプレッドシートに反映する挙動が追加されています。
function inputResult(resultData,customId,n) //ModalWindowで受け取ったデータをシートに書き込むモジュール
{
var inputSheet = SpreadSheet.getSheets()[1];
var inputData = resultData
var tableNum;
roundCount = n;
if (customId == "pointsA"){
tableNum = 1;
}else if (customId == "pointsB"){
tableNum = 2;
}else if (customId == "pointsC"){
tableNum = 3;
}else if (customId == "pointsD"){
tableNum = 4;
}
inputSheet.getRange((4 * tableNum)-1,roundCount * 4 + 1,4,1).setValues(inputData);
return inputData;
}
resultDataは入力値を配列で渡しています。
customIdはModalWindowのcustomId、nは現在の対戦が何回戦かを表しており、それぞれ入力するセル番地の指定に使用します。
実際の挙動を確認
それでは実際にコマンドを実行しながら挙動を確認してみようと思います。(諸事情によりbot、ユーザー名は隠しています)
値の入力先のシートはこちら。赤枠の中に点数が記入されます。
反省点・今後の展望など
私事ですが、半年ほど前に配属の希望が通り、晴れてソースコードを実際に触る業務をさせていただいております。
業務で得た知識をもとに久々にこのプロジェクトのコードを読んでみてまず思ったことが、
コードが汚い!!!!
でした…
特にインデントがぐちゃぐちゃで、ある程度は修正しましたがそれでも完全に整っているわけではありません。また、変数名もローワーキャメルケースの方が好みなので変更したりしています。
まだまだ直したりないと感じているので、後々リファクタリングを実施したいなと感じました。
他に追加したい機能としては、直近に取り組みたいものとしてはデータ送信後のメッセージを入力内容に沿ったものにするべきだったと感じています。現行だと送信を受け付けた旨のメッセージしか発出しないため入力内容が本当に正しいか確認するためにスプレッドシートの方を確認しなければなりません。
今回、ボタン機能がdiscord.js v13の新機能であったためv13で作成しました。discord.jsの現行バージョンはv14ですが、コードの後方互換性が大きく損なわれためバージョンアップをしようとするとかなり改修が必要になってしまい、バージョンに関しては選択ミスをしてしまったと感じています。
以前と比べて情報の探索やエラーの解決などの力がついてきた実感があり、個人的には満足のいく結果になったかなと思います。
最後になりますが、ここまでご覧いただき有難うございました。
参考文献