見出し画像

スプラの案件募集するdiscord botを作った話

スプラトゥーン3のコミュニティサーバーで導入していたゲーム案件募集するbotを自作して運用していたのでメモがてらアウトプットしておきます

※ここに於ける「案件」は一緒にゲームをする相手を募集することを指します

2024/2/9追記:コードを追記


導入していたdiscordサーバー

  • 全メンバー30人前後のクローズドサーバー

  • 案件数は多いときでおおよそ週に4~5回ほど

  • 月1程度の活動ノルマあり

導入経緯

案件参加の意思表示が統一されておらず分かりにくかった

大体の人はメッセージや返信形式での意思表示だったけども
たまに、募集メッセージに対して「🙋」のリアクションとか、「🥰」リアクションで意思表示をするなど
行けない人もリアクションをつけていたこともあったので、リアクションの種類によっては参加するのかしないのかが分かりにくかったことがありました

ここで「参加するのしないのどっちなの?」と聞ければよかったものの雰囲気を悪くさせまいと聞く人はいなかったそうな・・・

活動日誌を自動化したかった

月1ノルマがある以上運営としてはメンバーの活動傾向を記録する必要があったので、bot導入までの数カ月は案件終わりや週末に活動者の記録をポチポチスプシにつけてたのですが、大変面倒くさかったので自動化したくなりました

botの中身について

使用ツール

  • discord bot

  • Glitch(無料で使える圏内)

  • discord.js v14

  • Googleスプレッドシート

内容の設計

  • botのスラッシュコマンドを使用する

  • 上記の改善がしたかったので参加する・しないはボタン1クリックで済ませたい

  • 募集主に対して誰が参加ボタンを押したかはプッシュ通知が飛ぶようにする

  • 案件の〆ボタンを用意し、活動日誌をbotとスプシで自動化する

実際の挙動

下記のスラッシュコマンドで動かせるようにしました

/募集 [募集内容] [時間] [募集人数] [募集条件] [参加者①] [参加者②]

募集内容は選択形式でナワバリ、オープン、サーモンラン、プラベのような通常のルールに加えてイベント用(イベマ、フェス、バチコン、ビッグラン)の選択肢も用意
時間・募集人数・参加条件は入力形式で募集主が好きに書けるように
参加者①・②は募集時点ですでに募集主以外の参加者がいる場合にメンション指定すると参加者一覧に入るようにしています

実際募集をかけるとこんな感じのメッセージをbotが作ってくれる
誰かが参加ボタンを押すと参加者のメンションが追加されて募集主に通知が届く

裏でログを取る

〆ボタンを押すとスプシにだれがいつ遊んだかが記録できるようにしました
表示名やユーザー名はユニークじゃなかったのでdiscord IDを記録
傍から見たら十数桁の数字(ID)が羅列してるだけなのでワケ分からん

募集内容は取らなくても良かった
上記ログを基に日誌に追加

困ったこと・改善したこと

募集の締め忘れ

〆ボタンが押されないことにはログが記録されない且つ押間違い防止のために募集主しか〆ボタンが押せなかったので、運営(サーバーの管理者権限がある人)も〆ボタンが押せるように改善しました

募集からn時間経過とか日付変わったら自動〆なども検討したけどいつの募集か、ということをbotに認知させる必要があって大変面倒くさかったので諦め

乱用防止

募集があったので行こう!と思ったら募集が掛けられて5分も経ってないうちに(誰も集まっていない状態で)〆られていることがあったので募集から30分以内は募集を〆られないようにしました
これは完全に運営側のエゴなのでこの機能はなくてもいいのかな、とは思います

参加者の遅参・早抜け

21~23時の募集に「21時半から行きたい」「22時で抜けたい」などの要望が結構多かったのですが、botではどうしようもなかったのでメッセージでやり取りしてもらってました

各々ライフスタイルが異なるので(自分もやりがち)仕方ないかなぁ~とは思ってましたが、遅刻パターンは募集主のやりたい時間に始められないこともあったのでコミュニティとしては善し悪しどうなんだろう・・・🤔

やりたかったけどできなかったこと

ノルマ未達成メンバーに自動通知

シンプルにbotからスプシの記録を読み取ってメンバーに通知をするだけの機能
実装してコメントアウトの状態で置いてはいたけど本導入までは至りませんでした

入室通知

新規メンバーに最初何すればいいのかを促すためのメッセージを用意していました
これも実装してコメントアウトの状態で置いてはいたけど本導入までは至りませんでした

ブキルーレット機能

運営間で相談の上実装しないことに
webアプリでは作ってあったからそっちを使ってもらおうという流れ(現在はバラしたのでありません)

募集時のルールとステージを添付

やろうと思ったらできただろうけどネットに落ちてるAPIがグレーな感じがしたので断念

おしまい

最初は良かれと思って作ったけどあとからコマンドのオプションが多くてめんどくさいなと感じたので改善の余地ありでした
諸事情で、このbotはサーバーから撤退することにしたけどスプラ関係なくアイデアは誰かの参考になればいいなと思い書き下ろしてみました

ログ取りのことはほとんどのメンバーには言ってないので裏で行動を監視していたという点においては不快にさせたかも、と思うとその辺は反省

やる気と気力があれば生のコードについても書きたい

おわり

追記(実際のコード)

Glitchでdiscord botを使う手順とテンプレは以下のサイトを参考にしています

Glitch(処理)

const { Client, Events, GatewayIntentBits, ActionRowBuilder, ButtonBuilder, ButtonStyle,
  SlashCommandBuilder, ModalBuilder, TextInputBuilder, REST, Routes, TextInputStyle, EmbedBuilder,
  PermissionsBitField, Partials, User, ChannelType,
} = require("discord.js");
const fs = require('fs');
const originalPatch = User.prototype._patch;
User.prototype._patch = function (data) {
  originalPatch.call(this, data);
  this.globalName = data.global_name ?? null;
};

Object.defineProperty(User.prototype, 'name', {
  get() {
    return this.globalName ?? this.username;
  },
});

// gss
const { GoogleSpreadsheet } = require('google-spreadsheet');
const API_KEY_JSON = './api_key.json';
const CREDS = require(API_KEY_JSON);
const doc = new GoogleSpreadsheet('xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx');

const client = new Client({
  intents: [
    GatewayIntentBits.Guilds,
    GatewayIntentBits.GuildMembers,
    GatewayIntentBits.GuildMessages,
    GatewayIntentBits.MessageContent,
    GatewayIntentBits.GuildMessageReactions,
  ],
  'partials': [Partials.Channel]
});

const cron = require('node-cron');

// 動作させるサーバーのID
const GUILD = 'xxxxxxxxxxxxxxxxxxx'
const SHEET_NAME_LOG = '活動ログ'
const SHEET_NAME_DAY = '活動日誌(改)'

// コマンド取得
const commands = {}
const commandFiles = fs.readdirSync('./commands').filter(file => file.endsWith('.js'))
for (const file of commandFiles) {
  const command = require(`./commands/${file}`);
  commands[command.data.name] = command
}

// ボタンの定義
const joinButton = new ButtonBuilder()
  .setStyle(ButtonStyle.Success)
  .setLabel("参加")
  .setCustomId("join_button");

const cancelButton = new ButtonBuilder()
  .setStyle(ButtonStyle.Danger)
  .setLabel("参加キャンセル")
  .setCustomId("cancel_button");

const endButton = new ButtonBuilder()
  .setStyle(ButtonStyle.Secondary)
  .setLabel("〆")
  .setCustomId("end_button");

client.on("ready", async () => {
  // コマンド登録
  const data = []
  for (const commandName in commands) {
    data.push(commands[commandName].data)
  }
  await client.application.commands.set(data);
});

// 新規メンバー参加時
client.on('guildMemberAdd', async (member) => {
  if (member.guild.id !== GUILD) return;
  
  //シート情報
  await doc.useServiceAccountAuth(CREDS);
  await doc.loadInfo();
  const sheet = await doc.sheetsByTitle[SHEET_NAME_DAY];

  const options = { year: 'numeric', month: '2-digit', day: '2-digit', timeZone: 'Asia/Tokyo' };
  const formatter = new Intl.DateTimeFormat('ja-JP', options);
  const formattedDate = formatter.format(new Date());

  await sheet.loadCells()
  for (let i = 3; i <= sheet.rowCount; i++) {
    console.log(sheet.getCell(i, 1).value)
    if (sheet.getCell(i, 0).value == null && sheet.getCell(i, 1).value == null) {
      sheet.getCell(i, 0).value = formattedDate // A列に入室日付を書き込む
      sheet.getCell(i, 1).value = member.user.id; // B列にidを書き込む
      sheet.getCell(i, 2).value = member.user.globalName; // C列にnameを書き込む
      await sheet.saveUpdatedCells();
      break;
    }
  };
});


// スラッシュコマンド実行
client.on(Events.InteractionCreate, async (interaction) => {
  if (interaction.guild.id !== GUILD) return;
  const channelId = interaction.channelId;  // スラッシュコマンドが実行されたチャンネルID
  const channel = await interaction.guild.channels.fetch(channelId);  // チャンネルを取得
  const user = interaction.user;    // 募集ホスト
  
  if (interaction.isChatInputCommand()) {
    // コマンド実行
    const command = commands[interaction.commandName];
    if (!command) return;
    
    try {
      await command.execute(interaction);
    } catch (error) {
      console.error(error);
      await interaction.reply({
        content: "実行中にエラーが発生しました。",
        ephemeral: true,
      });
    }
  }
  
  // メンション 8時~23時まで@everyone
  var date = new Date();
  const currentHour = date.getHours() + 9;
  let mention = ""
  if (currentHour >= 8 && currentHour < 23) {
    mention = '@everyone'
  } else {
    mention = '@here'
  }
  
  // ボタンを活性化
  joinButton.setDisabled(false);
  cancelButton.setDisabled(false);
  endButton.setDisabled(false);
  
  // 募集
  if (interaction.commandName === "募集"){
    const detail = interaction.options.getString('募集内容');
    const count = interaction.options.getString('人数');
    const time = interaction.options.getString('時間');
    const conditions = interaction.options.getString('募集条件') ?? null;
    const member1 = interaction.options.getUser('参加者①') ?? null;
    const member2 = interaction.options.getUser('参加者②') ?? null;
    
    let message = `### ${user.toString()}` + 'さんの' + detail + '募集!';
    const embed = new EmbedBuilder()
      .setTitle(detail + '募集!')
      .addFields(
        { name:'募集人数', value: count},
        { name:'時間', value: time},
        { name:'募集条件', value: conditions ?? 'なし'},
      )
      .setColor('Random')
    
    let members = "### " + mention + " 新しい" + detail + "募集があります!"
     + '\n' + '**【↓参加者↓】**'
     + '\n' + `${user.toString()}`

    if(member1) {
      members += '\n' + `${member1.toString()}`
    } else if(member2) {
      members += '\n' + `${member2.toString()}`
    }

    await interaction.reply({
      content: message,
      embeds: [embed] ,
    });
    
    await channel.send({
      content: members,
      components: [new ActionRowBuilder().addComponents(joinButton, cancelButton, endButton)],
    });
  }
});

// ボタンのクリックイベント処理
client.on(Events.InteractionCreate, async (buttonInteraction) => {
  if (!buttonInteraction.isButton()) return;
  const pushUser = buttonInteraction.user;
  let editMessage = buttonInteraction.message.content;
  let pushNmae = buttonInteraction.member.nickname ?? pushUser.globalName
  
  const beforeMessage = await buttonInteraction.channel.messages.fetch({ before: buttonInteraction.message.id, limit: 1 })
    .then(messages => messages.last())
    .catch(console.error)
  const hostUser = beforeMessage.interaction.user
  const timeStamp = buttonInteraction.message.createdTimestamp
  
  let n2Array = editMessage.split('\n');
  let members = 0
  n2Array.forEach((element) => {
      if(element.indexOf('<@') !== -1) {
        members += 1
      }
  });

  // 参加ボタンが押された時
  if (buttonInteraction.customId === "join_button") {
    try {
      // 募集ホストが押した場合      
      if(pushUser === hostUser) {
        await buttonInteraction.reply({
          content: '募集ホストです',
          ephemeral: true
        });
      // 既に参加しているメンバーが押した場合
      } else if(editMessage.indexOf(pushUser.toString()) > 0) {
        await buttonInteraction.reply({
          content: '既に参加しています',
          ephemeral: true
        });
      // 非参加者が押した場合
      } else {
        const updatedMessage = editMessage + '\n' + `${pushUser.toString()}`;
        // メッセージを編集
        await buttonInteraction.message.edit({
          content: updatedMessage,
        });
        await buttonInteraction.reply({
          content: `${hostUser.toString()}` + '\n' + `${pushNmae}さんが参加表明しました`,
        });
      }
    } catch (error) {
      await buttonInteraction.reply({
          content: 'エラーが発生しました。\n募集には手動でリプライ頂き、運営までエラーの旨ご連絡ください。',
          ephemeral: true
        });
      console.log(error);
    } finally {

    }

  // キャンセルボタンが押された時
  } else if (buttonInteraction.customId === "cancel_button") {
    try {
      if(pushUser === hostUser) {
        console.log('ホストが押した')
        // 30分経過までは募集を〆られない
        if(new Date(timeStamp + (1000 * 30 * 60)) > new Date()) {
          await buttonInteraction.reply({
            content: '募集から30分経過するまでは募集を閉じることができません',
            ephemeral: true
          });
        } else {
          let updatedMessage = `${hostUser.toString()}さんの募集はキャンセルされました`;
          joinButton.setDisabled(true);
          cancelButton.setDisabled(true);
          endButton.setDisabled(true);
          // メッセージを編集
          await buttonInteraction.message.edit({
            content: updatedMessage,
            components: [new ActionRowBuilder().addComponents(joinButton, cancelButton, endButton)]
          });
          await buttonInteraction.reply({
            content: '募集を取り消しました',
            ephemeral: true
          });
        }
      } else if(editMessage.indexOf(pushUser.toString()) > 0) {
        console.log('参加取り消し')
        const updatedMessage = editMessage.replace('\n' + `${pushUser.toString()}`, "");

        // メッセージを編集
        await buttonInteraction.message.edit({
          content: updatedMessage,
        });
        await buttonInteraction.reply({
          content: '参加を取り消しました',
          ephemeral: true
        });
      } else {
        console.log('参加していない')
        await buttonInteraction.reply({
          content: '参加していません',
          ephemeral: true
        });
      }
    } catch (error) {
      await buttonInteraction.reply({
          content: 'エラーが発生しました。\n募集には手動でリプライ頂き、運営までエラーの旨ご連絡ください。',
          ephemeral: true
        });
      console.log(error);
    } finally {

    }

  // 〆ボタンが押された時
  } else if (buttonInteraction.customId === "end_button") {
    try {
      if(pushUser === hostUser || buttonInteraction.member.permissions.has('Administrator')) {
        let hostMessage = ""
        if(buttonInteraction.member.permissions.has('Administrator')) {
          console.log('管理者が押した')
          hostMessage = "(管理者による〆)"
        } else {
          console.log('ホストが押した')
        }
        // 参加者が0且つ、30分経過までは募集を〆られない
        if(new Date(timeStamp + (1000 * 30 * 60)) > new Date() && n2Array.length == 3) {
          await buttonInteraction.reply({
            content: '募集から30分経過するまでは募集を閉じることができません',
            ephemeral: true
          });
        } else {
          n2Array[0] = `${hostUser.toString()}さんの募集は〆!` + hostMessage;
          let updatedMessage = n2Array.join('\n');
          joinButton.setDisabled(true);
          cancelButton.setDisabled(true);
          endButton.setDisabled(true);

          await output(editMessage);  // ログ書き出し

          await buttonInteraction.message.edit({ 
            content: updatedMessage,
            components: [new ActionRowBuilder().addComponents(joinButton, cancelButton, endButton)]
          });
          await buttonInteraction.reply({
            content: '募集を〆ました',
            ephemeral: true
          });
        }
      } else {
        console.log('ホスト以外が押した')
        await buttonInteraction.reply({
          content: 'ホスト以外は〆られません',
          ephemeral: true
        });
      }
    } catch (error) {
      await buttonInteraction.reply({
          content: 'エラーが発生しました。\n募集には手動でリプライ頂き、運営までエラーの旨ご連絡ください。',
          ephemeral: true
        });
      console.log(error);
    } finally {

    }
  }
});


client.login(process.env.DISCORD_BOT_TOKEN_TAKOWASA);

/*
 * ログを出力
 * 
 * @param string message
 */
async function output(message) {
  try {
    await doc.useServiceAccountAuth(CREDS);
    await doc.loadInfo();
    const sheet = doc.sheetsByTitle[SHEET_NAME_LOG]; // シートのインデックスを指定
    
    var date = new Date().toLocaleString('ja-JP', { timeZone: 'Asia/Tokyo' })
    
    let messageLine = message.split('\n');
    const regexId = /@([^>]+)>/;
    const regexDetail = /新しい([^募集]+)募集/;

    for (let i = 0; i<messageLine.length; i++) {
      let id = messageLine[i].match(regexId)
      let detail = messageLine[i].match(regexDetail)
      if(id) {
        messageLine[i] = id[1];
      }
      if(detail) {
        messageLine[i] = detail[1];
      }
    };
    
    // データをスプレッドシートに追加
    await sheet.addRow({
      時刻: date,
      募集: messageLine[0],
      募集ホスト: messageLine[2],
      参加者1: messageLine[3] ?? null,
      参加者2: messageLine[4] ?? null,
      参加者3: messageLine[5] ?? null,
      参加者4: messageLine[6] ?? null,
      参加者5: messageLine[7] ?? null,
      参加者6: messageLine[8] ?? null,
      参加者7: messageLine[9] ?? null,
      参加者8: messageLine[10] ?? null,
      参加者9: messageLine[11] ?? null,
    });

    console.log('Message recorded in spreadsheet.');
  } catch (error) {
    console.error('Error recording message:', error);
  }
}

Glitch(コマンド登録)

const { SlashCommandBuilder  } = require('discord.js');
module.exports = {
    data: new SlashCommandBuilder()
        .setName('募集')
        .setDescription('募集を掛けます')
        .addStringOption(option =>
            option.setName('募集内容')
                .setDescription('選択してください')
                .setRequired(true)
                .addChoices(
                  {name:'ナワバリ', value:'ナワバリ'},
                  {name:'オープンマッチ', value:'オープンマッチ'},
                  {name:'サーモンラン', value:'サーモンラン'},
                  {name:'プラベ', value:'プラベ'},
                  {name:'イベントマッチ', value:'イベントマッチ'},
                  {name:'フェス', value:'フェス'},
                  {name:'ビッグラン', value:'ビッグラン'},
                  {name:'バイトチームコンテスト', value:'バイトチームコンテスト'}
                ))
        .addStringOption(option =>
            option.setName('時間')
                .setDescription('21時~、今から、など入力ください')
                .setRequired(true))
        .addStringOption(option =>
            option.setName('人数')
                .setDescription('@1~3、など入力ください')
                .setRequired(true))
        .addStringOption(option =>
            option.setName('募集条件')
                .setDescription('オカシラ1回、ブキ練習、など入力ください'))
        .addUserOption(option =>
            option.setName('参加者①')
                .setDescription('事前に決まっている参加者①'))
        .addUserOption(option =>
            option.setName('参加者②')
                .setDescription('事前に決まっている参加者②')),
    async execute(interaction) {
    }
}

Googleスプレッドシート

function inputActivityDiaryTest() {
  var ss = SpreadsheetApp.getActiveSpreadsheet(); // 活動日誌のシート
  var sheet = ss.getSheetByName("活動日誌(改)"); // botから送信されたログが記載されているシート
  var dataSheet = ss.getSheetByName("活動ログ"); // データシートのデータを取得
  var data = dataSheet.getDataRange().getValues();

  var lastColumn = sheet.getLastColumn();
  var headers = sheet.getRange(1, 1, 1, lastColumn).getValues()[0];
  var columnRange = sheet.getRange('B:B'); // 列を指定
  var columnValues = columnRange.getValues();

  // 2次元配列を1次元配列に変換
  var columnArray = columnValues.map(function(row) {
    return row[0];
  });

  for (var j = 1; j < data.length; j++) {
    Logger.log(data[j])
    Logger.log(data[j][data[j].length - 1])
    // チェック欄に●がついている場合はスキップ
    if(data[j][data[j].length - 1] == '●') {
      Logger.log('コンテニュー')
      continue;
    }
    for (var i = 0; i < headers.length; i++) {
      var cellDate = new Date(headers[i]);
      var dataDate = new Date(data[j][0]);
      dataDate.setHours(0,0,0,0);

      // 日付が一致すれば◯をつける
      if ( cellDate.getTime() == dataDate.getTime()) {
        for (var l = 2; l < data[j].length; l++) {
          Logger.log(columnArray.indexOf(data[j][l]));
          var line = columnArray.indexOf(data[j][l]);
          if(line > 0) {
            Logger.log(sheet.getRange(line + 1, i + 1));
            sheet.getRange(line + 1, i + 1).setValue("〇");
            sheet.getRange(line + 1, 1).setValue(Utilities.formatDate(dataDate, "JST","M/d"));
          }
        }
      }
    }
    // ログ一覧のチェック欄に〇をつける
    dataSheet.getRange(j + 1, data[j].length).setValue("●");
  }
}


よろしければサポートお願いします! いただいたサポートはクリエイターとしての活動費に使わせていただきます!