12
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Discordで勉強時間記録botを作る

Last updated at Posted at 2022-12-18

はじめに

皆さん、勉強はしていますか?
僕は、Discord勉強時間を可視化できたら良いな〜 と思って、StudyRoomBOTを作ってみました。この記事の最後の方に、注意点とともに招待リンクを貼ってあるので、注意点をわかっていただける場合はぜひ招待してください。
今後アップデートする自分への仕様書も兼ねて、記録を残したいと思います。参考になれば幸いです。

なお、この記事では区別のため、Discordのユーザーの集まりである法のサーバーを「ギルド」、BOTを動かすほうのサーバーを「鯖」と呼ぶことにします。

BOTの仕様

機能

  • 登録されたVCに参加している時間を記録する
  • VCの登録や削除を管理者が行えるようにする
  • 過去5週間の週別データと、過去7日間の日別データ、累計データを見れるようにする
  • 直近7日間の勉強時間をもとにランク付けをする
  • 自習室VC参加中は、ロールを付与する(BOTも参加しているすべてのサーバーで付与される)
  • ギルド数や現在勉強中の人数を、アクティビティステータスに表示する

スラッシュコマンド

  • about
    botの概要を表示させるコマンド

  • help
    botのヘルプを表示させるコマンド

  • studydate
    過去7日間の勉強データ、累計勉強時間、現在のランクを表示

  • studyweek
    過去5週間の週別勉強データ、累計勉強時間、現在のランクを表示

  • admin
    管理者向けのガイド。管理者限定コマンド

  • studyroomadd
    現在参加しているVCを自習室として登録する。管理者限定コマンド

  • studyroomdel
    現在参加しているVCの自習室としての登録を解除する。管理者限定コマンド

ランク

直近7日間の勉強時間を元に、以下のランク付けをします。

ランク 過去7日間の時間
プラチナ 48時間以上
金色 42時間以上
赤色 35時間以上
橙色 24時間以上
黄色 20時間以上
青色 14時間以上
水色 10時間以上
緑色 7時間以上
茶色 3時間以上
灰色 3時間未満

使用するシステム

Node.js v17.9.1
Discord.js v14.6
その他パッケージは、package.jsonで確認してください

ディレクトリ構成

ファイルは以下のような構成になっています。

StudyRoomBOT
└botmain.js             スラッシュコマンドの登録・関数の呼び出し・スタータスの設定等
└functions
    └joinFunc.js        BOTがギルドに参加したときや、ギルドに新規ユーザーが参加したときの処理の関数の宣言
    └studyRoom.js    VCの参加処理や切断処理を行う関数の宣言
└commands
    └studyCommands.js  スラッシュコマンドの処理
└config.json        トークンやVCのID等の保存
└studyroom.json      セーブデータの保存
└package.json       npmによって自動生成される、モジュール管理用ファイル

ソースコードの解説

このBOTはオープンソースになっています。ソースコードはNITKC-22DEVのGitHubに公開してます。

config.json

中身は以下のようになっています。

config.json
{
	"token": "トークン",
    "studyVC": [
        "チャンネルID-1",
        "チャンネルID-2"
    ],
	"role": [
		{
			"guild": "ギルドID",
			"id": "ロールID"
		},
		{
			"guild": "ギルドID",
			"id": "ロールID"
		}
	]
}
key value Type
token DiscordBOTのトークン 文字列
studyVC 自習室のチャンネルID 文字列型配列
role guild:BOTが参加しているギルドID
id:そのサーバーのstudying nowロールのID
オブジェクト型配列

studyVCとroleは、チャンネルが追加されたり、サーバーに参加するたびに増えていきます。

studyroom.json

studyroom.jsonは、以下のようにdateにすべてのセーブデータがオブジェクト型の配列として格納されています。
なお、dataではなくdateなのは誤字です。これをミスった関係で補完ですべてのコードが誤字りました。

studyroom.json
{
	"date": [
		{
			"uid": "ユーザーID",
			"name": "ユーザー名#0000",
			"icon": "アイコンURL",
			"lastJoin": 0,
			"study": [
				0,
				0,
				0,
				0,
				0,
				0,
				0
			],
			"StudyAll": 0,
			"task": [
				0,
				0,
				0,
				0,
				0,
				0,
				0
			],
			"TaskAll": 0,
			"now": false,
			"StudyWeek": [
				0,
				0,
				0,
				0
			],
			"TaskWeek": [
				0,
				0,
				0,
				0
			],
			"guild": [
				"ギルドID",
                "ギルドID"
			]
		}
	]
}
Key Value Type
uid DiscordのユーザーID 文字列
name Discordのユーザー名 文字列
icon DiscordのアイコンURL 文字列
guild 最後に参加したUNIX TIME 数値
study 直近7日間の勉強時間(単位:秒)
study[0]が今日、study[6]が7日前
数値型配列
studyAll 今までの勉強時間の合計(単位:秒) 数値
task 直近7日間の作業時間(単位:秒)
task[0]が今日、task[6]が7日前
数値型配列
taskAll 今までの作業時間の合計(単位:秒) 数値
now 現在VCに参加しているかどうか bool
StudyWeek 直近5週間の勉強時間(単位:秒)
StudyWeek[0]が今週、StudyWeek[4]が5週前
数値型配列
TaskWeek 直近5週間の作業時間(単位:秒)
TaskWeek[0]が今週、TaskWeek[4]が5週前
数値型配列
guild その人が参加しているサーバーID 文字列型配列

botmain.js

ファイル全体は GitHubから見れます。
ソースコードと一部順番が違う場合がありますが、動作に問題はないです。

ヘッダー部分

configやモジュール、intentsや外部に記述したjsファイルを以下の部分で読み込んでいます。

botmain.js
const { Client, GatewayIntentBits, Partials, Collection, EmbedBuilder} = require('discord.js');
let config = require('./config.json')
const studyroom = require('./functions/studyRoom.js');
const join = require('./functions/joinFunc.js')
const dotenv = require('dotenv');
const path = require('path');
const fs = require('fs');
const cron = require('node-cron');
require('date-utils');
dotenv.config();
const client = new Client({
    intents: [
        GatewayIntentBits.Guilds,
        GatewayIntentBits.GuildVoiceStates,
        GatewayIntentBits.GuildMembers,
        GatewayIntentBits.GuildMessages,
        GatewayIntentBits.GuildMessageReactions,
        GatewayIntentBits.MessageContent,

    ],
    partials: [Partials.Channel],
});
module.exports.client=client;

また、以下のコードでReadyイベント実行時にcommandsファイルにあるすべてのjsファイルを読み込み、そこからスラッシュコマンドを登録しています。

botmain.js
const commandsPath = path.join(__dirname, 'commands');
const commandFiles = fs.readdirSync(commandsPath).filter(file => file.endsWith('.js'));
client.commands = new Collection();
module.exports = client.commands;


/*スラッシュコマンド登録*/
client.once("ready", async () => {
    for (const file of commandFiles) {
        const filePath = path.join(commandsPath, file);
        const command = require(filePath);
        for (let i = 0; i < command.length; i++) {
            client.commands.set(command[i].data.name, command[i]);
        }

    }
    console.log("Ready!");
});

関数呼び出し部分+α

botmain.js
client.on("interactionCreate", async (interaction) => {
    if (!interaction.isCommand()) {
        return;
    }
    const command = interaction.client.commands.get(interaction.commandName);

    if (!command) return;
    console.log("SlashCommand : "+command.data.name);
    try {
        await command.execute(interaction);
    } catch (error) {
        console.error(error);
        await interaction.reply({ content: 'エラーが発生しました。[サポートサーバー](https://discord.gg/fpEjBHTAqy)にて連絡していただけると助かります。', ephemeral: true });
    }
});

このコードで、スラッシュコマンドのエラーを吐く部分を実装しています。僕がいつも使っているテンプレです

botmain.js
/*VC更新時*/
client.on('voiceStateUpdate', (oldState, newState) => {
    studyroom.func(oldState, newState)
})

/*BOT参加時*/
client.on('guildCreate', async guild => {
    await join.bot(guild)
    console.log("ギルド参加処理")
})

/*ユーザー参加時*/
client.on('guildMemberAdd', async member => {
    await join.user(member)
    console.log("ユーザー参加処理")
})

/*ユーザー退出時*/
client.on('guildMemberRemove', async member => {
    await join.rmuser(member)
    console.log("ユーザー退出処理")
})

この部分では、何かのイベントが実行されたときに、対応する処理の関数を呼び出しています。

誰かがVCに入退出したり、ミュートするなど、何かしらの操作が行われた場合に反応する「voiceStateUpdate」イベントが発生したら、functions/sturyroom.jsで定義されているfunc関数を呼び出しています。
このイベント発生時には、イベント発生前のそのユーザーのVCの状態であるoldStateと、イベント発生後のユーザーのVCの状態であるnewStateの2つのオブジェクト型が渡されるので、そのまま引数として関数に渡しています。

BOTがギルドに参加したときに反応する「guildCreate」イベントが発生したら、functions/joinUser.jsで定義されているbot関数を呼び出しています。このイベント発生時には、参加ギルドに関するデータがオブジェクト型で渡されているので、bot関数に引数として渡しています。

ギルドに新規ユーザーが来た時に反応する「guildMemberAdd」イベントが発生したら、funcitions/joinUser.jsで定義されているuser関数を呼び出しています。このイベント発生時には参加ユーザーに関するデータがオブジェクト型で渡されるので、引数という形でuser関数に渡しています。

ギルドからユーザーが抜けたり、botがギルドから抜けた時に反応する「guildMemberRemove」イベントが発生したら、funcitions/joinUser.jsで定義されているrmuser関数を呼び出しています。このイベント発生時には、退出したメンバーのデータが渡されるので、それを引数にして渡しています。

botmain.js
cron.schedule('0 0 * * *',() => {
    studyroom.update();
})

cron.schedule('* * * * *',() => {
    let date = new Date(); //現在の時刻を取得
    let json = JSON.parse(fs.readFileSync('./studyroom.json', 'utf8')); //jsonを読み込み
    let user = json.date.length //dateの長さを取得することで、登録者数を取得
    let time = Math.floor(date.getTime() / 1000 / 60)%6 //現在時刻を6で割った余りを求める
    let now = json.date.filter(function(item, index){ /*今入ってる人を列挙*/
        if (item.now === true ) return true;
    });

    if(time === 0){
        client.user.setPresence({
            activities: [{
                name: client.guilds.cache.size+"サーバーに導入中" //サーバー数をclient.guild.cache.sizeで取得
            }],
        });
    }
    else if(time === 1){
        client.user.setPresence({
            activities: [{
                name: user+"人のセーブデータ登録済み"
            }],
        });
    }
    else if(time === 2){
        client.user.setPresence({
            activities: [{
                name: "ヘルプ:/help"
            }],
        });
    }
    else if(time === 3){
        client.user.setPresence({
            activities: [{
                name: "日別データ:/studydate"
            }],
        });
    }
    else if(time === 4){
        client.user.setPresence({
            activities: [{
                name: "週別データ:/studyweek"
            }],
        });
    }
    else{
        client.user.setPresence({
            activities: [{
                name: now.length + "人が勉強" //現在VCに入ってる人の人数を
            }],
        });
    }
})

ここでは、cronを使って定期的に動作を行っています。

上のほうでは、'0 0 * * *' を指定することによって、毎日夜0時にfunctions/studyroom.jsに記述されているupdate関数を呼び出しています。studyroom.jsonの直近7日間のデータや、直近5週間のデータの更新を行う関数です。

下のほうでは、'* * * * *'を指定することによって、毎分「○○をプレイ中」の部分を更新してます。アクティビティステータスと呼ばれるやつです。client.user.setPresence()の引数に{name:"スタータスメッセージ"}のようなオブジェクト型を渡すことで、そのステータスメッセージに変更されます。現在時刻を6で割った余りで分岐させることで、6種類の表示を順に回しています。

botmain.js
client.login(config.token);

config.tokenにトークンがあるので、いつものおまじない。

studyCommands.js

ソースコード全体はGitHubを見てください。
このファイルでは、スラッシュコマンドの定義を行っています。なお、未実装の開発中の機能がmainブランチに間違ってコメントアウトされた状態でプッシュされていますが、ここでは触れません。

studyCommands.js
const { SlashCommandBuilder, EmbedBuilder, GuildMember} = require('discord.js')
const fs = require("fs");
const date = JSON.parse(fs.readFileSync('./config.json', 'utf8'));
const packageVer = require('../package.json')

module.exports =
    []

まずは、discord.jsとfs(json書き込みを行うためのもの)、それとconfig.jsonを読み込んでいます。
JSON.parse(fs.readFileSync(JSONファイルのパス, 'utf8'))とすることで、fsで書き込み可能な形式でjsonを読み込めます。
更に、パッケージバージョンを取得するためにpackage.jsonを読み込みます。

module.exportに、オブジェクト型配列としてコマンドの処理を代入することでスラッシュコマンドを登録できます。

aboutコマンド

以下に書いていく内容をオブジェクト型としてまとめた上で、それらを配列にしてmodule.exportに渡します。

studyCommands.js
{
    data: new SlashCommandBuilder()
                .setName('about')
                .setDescription('BOTの概要を表示します'),

コマンドの名前と、その説明(画像の部分に表示される)を登録します。
image.png

studyCommands.js
    async execute(interaction) {
                const embed = new EmbedBuilder()
                    .setColor(0x00A0EA)
                    .setTitle('StudyRoomBOT - about')
                    .setAuthor({
                        name: "StudyRoom BOT",
                        iconURL: 'https://cdn.discordapp.com/avatars/1046304160448000072/aa9294120328c547950c7d95f01435c9.webp?size=320',
                        url: 'https://discord.com/invite/fpEjBHTAqy'
                    })
                    .setDescription('このBOTは、オープンソースで開発されています。[GitHub](https://github.com/NITKC22s/StudyRoomBOT)にソースコードがあります。')
                    .addFields(
                        {
                            name:"BOTバージョン",
                            value:'v' + packageVer.version
                        },
                        {
                            name:"About NITKC-22DEV",
                            value:"木更津高専1年生の学年非公式Discordサーバー向けに開発したBOTを、せっかくなら外部向けに公開しようという試みです。\nメンバーは[kokastar](https://github.com/starkoka)、[Naotiki](https://github.com/naotiki)、[KouRo](https://github.com/Kou-Ro)、[NXVZBGBFBEN](https://github.com/NXVZBGBFBEN)の4人です。"
                        },
                        {
                            name:"このBOTの開発者",
                            value:"[kokastar](https://github.com/starkoka)"
                        },
                        {
                            name:"サポートサーバー",
                            value:"NITKC-22DEVの[サポートサーバー](https://discord.com/invite/fpEjBHTAqy)内に専用のカテゴリがあります。"
                        }


                    )
                    .setTimestamp()
                    .setFooter({ text: 'Developed by NITKC-22DEV' ,iconURL: 'https://avatars.githubusercontent.com/u/107338867?s=200&v=4'});
                await interaction.reply({ embeds: [embed] });
}

async execute(interaction){}の中で、スラッシュコマンドの動作を記述していきます。このとき、変数interactionにはコマンド実行者の情報や実行されたギルドの情報が入っています。

まず、embedを宣言し、EmbedBuilderで埋め込みメッセージを作成します。今回使っているオプションを紹介します。どのように表示される要素なのかは、その下の画像を参考にしてください。なお、文章はMarkdown記法で装飾でき、\nで改行もできます。

オプション 説明
setColor 埋め込みメッセージの左側の色の指定 数値型(16進数も可)
setTitle 埋め込みメッセージのタイトルを指定 文字列型
setAuthor 埋め込みメッセージの作成者情報の指定
name:名前
iconURL:アイコンとなる画像のURL
url:タイトルをクリックすると飛ぶURL
オブジェクト型
setDescription 埋め込みメッセージの本文 文字列型
addFields フィールドの内容を指定
name:フィールドの名前
value:フィールドの内容
オブジェクト型配列
setTiestamp 送信時刻を埋め込みます ----
setFooter フッターを設定します
text:フッターのテキストの指定
iconURL:アイコンとなる画像のURL
オブジェクト型

image.png

packageVer.versionには、Package.jsonに書かれているバージョン情報を読み取ります。そのため、自動でバージョン情報をが表示されます。

そして、最後にawait interaction.reply({ embeds: [embed] })でスラッシュコマンドに対して返信します。interaction.replyとすると、interactionが生成されたスラッシュコマンドにし対して返信されます。

helpコマンド

aboutコマンドとほぼ同じです。内容だけ変わっています。

studyCommands.js
{
            data: new SlashCommandBuilder()
                .setName('help')
                .setDescription('StudyroomBOTの説明です'),
            async execute(interaction) {
                const embed = new EmbedBuilder()
                    .setColor(0x00A0EA)
                    .setTitle('StudyRoomBOT - ヘルプ')
                    .setAuthor({
                        name: "StudyRoom BOT",
                        iconURL: 'https://cdn.discordapp.com/avatars/1046304160448000072/aa9294120328c547950c7d95f01435c9.webp?size=320',
                        url: 'https://discord.com/invite/fpEjBHTAqy'
                    })
                    .setDescription('ボイスチャットに接続している時間を勉強している時間とみなし、勉強時間を記録してくれるBOTです。')
                    .addFields(
                        {
                            name:"対象ボイスチャット",
                            value:"管理者が設定したVCのみが記録の対象となります。管理者の方で詳しく知りたい場合は、/admin を実行してください。"
                        },
                        {
                            name:"データ確認方法",
                            value:"以下のコマンドで、データを確認できます。\n日別データ:/studydate\n週別データ:/studyweek\nなお、一度も記録をしてない場合はデータがないので、対象のボイスチャットに一度参加してから実行してみてください。"
                        },
                        {
                            name:"ランク",
                            value:"直近7日間の勉強時間に応じて、ランクが設定されます。ランクは以下のように設定されています。\n\nプラチナ:48時間以上\n金色:42時間以上\n赤色:35時間以上\n橙色:24時間以上\n黄色:20時間以上\n青色:14時間以上\n水色:10時間以上\n緑色:7時間以上\n茶色:3時間以上\n灰色:3時間未満\n\n※このデータは小数点以下切り捨てで表示されるため、すべての値を合計しても合計値に届かないことがあります。"
                        },
                        {
                            name:"サポート",
                            value:"エラーの報告や、質問、新機能の提案等はこちらの[サポートサーバー](https://discord.com/invite/fpEjBHTAqy)で対応しています。"
                        },
                        {
                            name:"招待リンク",
                            value:"[こちらのリンク](https://00m.in/gY6eP)から招待することができます。"
                        },
                        {
                            name:"このBOTの情報や開発者の情報",
                            value:"/about コマンドを使用してください。"
                        }


                    )
                    .setTimestamp()
                    .setFooter({ text: 'Developed by NITKC-22DEV' ,iconURL: 'https://avatars.githubusercontent.com/u/107338867?s=200&v=4'});
                await interaction.reply({ embeds: [embed] });
            },

adminコマンド

studyCommands.js
{
            data: new SlashCommandBuilder()
                .setName('admin')
                .setDefaultMemberPermissions(1<<3) //管理者専用
                .setDescription('StudyRoomBOTの管理者向けメッセージです'),

このコマンドは、他のコマンドと異なるポイントがあります。管理者のみが実行できるようにするために、.setDefaultMemberPermissions(1<<3)を追加しました。これは、スラッシュコマンドが使える権限を指定するもので、1<<3を指定すると管理者になります。

studyCommands.js
    async execute(interaction) {
                const embed = new EmbedBuilder()
                    .setColor(0x00A0EA)
                    .setTitle('管理者の皆さんへ')
                    .setAuthor({
                        name: "StudyRoom BOT",
                        iconURL: 'https://cdn.discordapp.com/avatars/1046304160448000072/aa9294120328c547950c7d95f01435c9.webp?size=320',
                        url: 'https://discord.com/invite/fpEjBHTAqy'
                    })
                    .setDescription('StudyRoomBOTの導入ありがとうございます。以下に管理者向けの説明を記載します。')
                    .addFields(
                        {
                            name: "対象VC管理方法",
                            value: "管理したいVCに入りながら以下のコマンドを実行することで管理できます。\n追加:/studyroomadd\n削除:/studyroomdel"
                        },
                        {
                            name: "おすすめの使用方法 - VC",
                            value: "全員の発言権がない、静かに自習するVCと、発言権がありみんなで教え合いながら勉強するVCを用意することで、様々なニーズに答えることが可能になります。是非参考にしてみてください。"
                        },
                        {
                            name: "おすすめの使用方法 - 勉強中ロール",
                            value: "勉強中は、その人に「Studying now」というロールが付与されます。このロールの権限を上位に持っていき、チャンネル閲覧制限をかけることにより、勉強中はDiscordの誘惑から遮断される などの工夫ができます。ぜひご活用ください。なお、このロールは削除しないでください。"
                        },
                        {
                            name: "BOTのアップデートのついて",
                            value: "日々BOTを良くするために開発を続けています。そのため、不定期でアップデートやメンテナンスを行います。アップデートやメンテナンスの情報は[サポートサーバー](https://discord.com/invite/fpEjBHTAqy)にて配信しているので、ぜひ参加してください。アナウンスチャンネルもあるので、ぜひ活用してください。"
                        },
                        {
                            name: "エラー発生時",
                            value: "エラーが発生しないよう日々努力していますが、エラーが発生したりbotが停止することがあるかもしれません。その場合は、[サポートサーバー](https://discord.com/invite/fpEjBHTAqy)にて連絡をしてもらえると助かります。"
                        },
                        {
                            name: "その他",
                            value: "質問や意見、提案等は遠慮なくこちらの[サポートサーバー](https://discord.com/invite/fpEjBHTAqy)からお問い合わせください。"
                        }
                    )
                    .setTimestamp()
                    .setFooter({
                        text: 'Developed by NITKC-22DEV',
                        iconURL: 'https://avatars.githubusercontent.com/u/107338867?s=200&v=4'
                    });

更に、コマンドの結果が本人だけに見えるようにするために、特殊なオプションを追加しました。replyの引数のオブジェクト型にephemeral: trueを入れることで、コマンド実行者のみが見れるようになります。

studyCommends.js
    await interaction.reply({embeds: [embed], ephemeral: true});
}

studyroomadd

studyCommands.js
{
            data: new SlashCommandBuilder()
                .setName('studyroomadd')
                .setDefaultMemberPermissions(1<<3) //管理者専用
                .setDescription('現在参加しているVCを自習室に追加します。'),
            async execute(interaction) {
                //現在入ってるVC取得
                let user = interaction.user.id
                let guild = interaction.guild

                if (guild.voiceStates.cache.get(user) === undefined){
                    await interaction.reply({ content: '追加したいVCに参加してから実行してください。', ephemeral: true });
                }

このコマンドも管理者のみの権限にします。
まず、interactionからユーザーIDをuser、ギルドIDをguildに代入します。

次に、コマンド実行者がVCに接続しているか確認します。現在参加しているVCを自習室に追加するため、VCに参加していない場合には参加してから実行することを通知する必要があります。
これを確認するためには、guild.voiceStates.cache.get(user)がundefinedであるかどうかを判定します。guild.voiceStates.cacheには、現在VCに参加している人の一覧があるので、.get(user)で該当のユーザーを取得します。存在する場合にはそのユーザーのデータが返され、存在しない場合にはundefinedが返されます。もしもundefinedだった場合は、参加してから実行するよう返信します。elseの場合は、以下で解説するコードで処理をします。

studyCommands.js

                else{
                    //現在入ってるVC取得 その2
                    let id = guild.voiceStates.cache.get(user).channelId
                    let name = guild.channels.cache.get(id).name

                    const date = JSON.parse(fs.readFileSync('./config.json', 'utf8'));
                    let VC=date.studyVC.find(el => el === id);
                    if(VC === undefined){
                        date.studyVC.push(id)
                        fs.writeFileSync('./config.json', JSON.stringify(date,null ,"\t"));
                        await interaction.reply({ content: name + "(ID:"+ id + ")" + "を自習室に追加しました", ephemeral: true });
                    }
                    else{
                        await interaction.reply({ content: name + "(ID:"+ id + ")" + "はすでに自習室に追加されています。", ephemeral: true });
                    }
                }

            },
        },

先程も取得したguild.voiceStates.cache.get(user)のchannelIdには現在そのユーザーが参加しているVCのIDが格納されているので、それを変数idに代入します。更に、guild.channels.cache.get(id)では同じようにチャンネル情報を取得でき、nameにはチャンネル名が格納されているので、変数nameに代入します。

そしたら、書き込み可能な形式でconfig.jsonを読み込みます。そして、config.jsonのstudyVC配列の中に、現在参加しているVCがあるかどうかを.findを使って探します。存在していればその中身が返され、存在しなければundefinedが返されます。studyVC配列には自習室として登録されているVCのIDが格納されているので、もしもunderfinedが返されたら未登録なので登録処理を行い、なにかデータが返されたらすでに登録済みであるため、追加の処理は不要ということになるわけです。

もしも未登録なのであれば、.pushを使って配列の一番うしろにidを追加した上で書き込み、そのチャンネルが登録完了したということを返信します。登録済みであれば、すでに登録されているということを返信します。どちらも、その人しか見れないようにephemeralをtrueにしておきました。

studyroomdel

studyroomaddコマンドとほとんど同じ仕様になっています。

studyCommands.js
{
            data: new SlashCommandBuilder()
                .setName('studyroomdel')
                .setDefaultMemberPermissions(1<<3) //管理者専用
                .setDescription('現在参加しているVCを自習室から削除します。'),
            async execute(interaction) {

                //現在入ってるVCを取得
                let user = interaction.user.id
                let guild = interaction.guild

                if (guild.voiceStates.cache.get(user) === undefined){
                    await interaction.reply({ content: '削除したいVCに参加してから実行してください。', ephemeral: true });
                }
                else{
                    //現在入ってるVC取得 その2
                    let id = guild.voiceStates.cache.get(user).channelId
                    let name = guild.channels.cache.get(id).name

                    const date = JSON.parse(fs.readFileSync('./config.json', 'utf8'));
                    let VC=date.studyVC.find(item => item === id);
                    if(VC === undefined){
                        await interaction.reply({ content: name + "(ID:"+ id + ")" + "は自習室に追加されていません。/studyroomadd コマンドで追加できます。", ephemeral: true });
                    }
                    else{
                        date.studyVC=date.studyVC.filter(item => item !== id)
                        fs.writeFileSync('./config.json', JSON.stringify(date,null ,"\t"));
                        await interaction.reply({ content: name + "(ID:"+ id + ")" + "を自習室から削除しました", ephemeral: true });

                    }
                }
            },

ただし、今回は削除をしたいので、もしstudyVC配列にIDが登録されていれば、その要素を削除するようにします。
.filterを活用することで、その条件を見たすもののみを残した配列にすることができます。今回の場合、item !== idを指定していますから、idと等しくない要素のみがstudyVCに上書きされる仕組みになっています。

studydate

studyCommands.js
{
            data: new SlashCommandBuilder()
                .setName('studydate')
                .setDescription('自習室の日別データを表示します')
                .addUserOption(option =>
                    option
                        .setName('ユーザー')
                        .setDescription('記録を見たいユーザーを指定します')
                        .setRequired(true)
                ),

            async execute(interaction) {
                //jsonの読み込みとユーザーデータの取り出し
                const date = JSON.parse(fs.readFileSync('./studyroom.json', 'utf8'));
                let option = interaction.options.data[0].value;
                let user=date.date.find(date => date.uid === option);
                let embed;
                if(user === undefined){
                    embed = new EmbedBuilder()
                        .setColor(0xD9D9D9)
                        .setTitle('データが見つかりません')
                        .setAuthor({
                            name: "StudyRoom BOT",
                            iconURL: 'https://cdn.discordapp.com/avatars/1046304160448000072/aa9294120328c547950c7d95f01435c9.webp?size=320',
                            url: 'https://discord.com/invite/fpEjBHTAqy'
                        })
                        .setDescription('データが存在しないか、破損しています。このBOTが追加されたばかりの場合は、データが作成中の可能性があります。そうでない場合、VCに参加してからもう一度試してください。それでもエラーが起きる場合は、管理者に連絡してください。')
                        .setTimestamp()
                        .setFooter({ text: 'Developed by NITKC-22DEV' ,iconURL: 'https://avatars.githubusercontent.com/u/107338867?s=200&v=4'});
                }

このコマンドは、オプションでユーザーを指定することでそのユーザーのデータを見ることができるコマンドです。そのため、.addUserOptionを使ってユーザーを指定できるようにします。これがないとデータが見れないため、.setRequiredをtrueにすることで必須オプションにしました。

その後、studyroom.jsを書き込み可能な形式で読み込んだあと、コマンドで指定したユーザーの情報をoption変数に入れます。スラッシュコマンドのn番目のオプションは、interaction.options.date[n]に格納されていて、更にそこのvalueにオプションの中身、今回はユーザーIDが入っています。
そして、studyroom.jsonからdate.uidがoptionと一致する要素を見つけてuser変数に入れます。これがそのユーザーのデータとなります。もしもデータが存在しないユーザー(BOT等)を指定した場合は、undefinedが入ります。最後に、埋め込みメッセージのデータを入れるembed変数を用意したら下準備は完了ですy。

もしuserがundefinedならデータが存在しないため、データが見つからないということを伝えるための埋め込みメッセージを入れてあげます。そうでなければ、以下のようなコードを実行していきます。

studyCommands.js
                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                                    else{

                    let hour = user.study.reduce((sum, element) => sum + element, 0)/3600;
                    let color;
                    let rank;
                    if(hour >= 48){
                        color = 0x6DBCD1
                        rank = "Platinum";
                    }
                    else if(hour >= 42){
                        color = 0xFFEB99
                        rank = "Gold";
                    }
                    else if(hour >= 35){
                        color = 0xF00400
                        rank = "Red";
                    }
                    else if(hour >= 24){
                        color = 0xF47A00
                        rank = "Orange"
                    }
                    else if(hour >= 20){
                        color = 0xBCBC00
                        rank = "Yellow"
                    }
                    else if(hour >= 14){
                        color = 0x0000F4
                        rank = "Blue"
                    }
                    else if(hour >= 10){
                        color = 0x00B5F7
                        rank = "Light Blue"
                    }
                    else if(hour >= 7){
                        color = 0x007B00
                        rank = "Green"
                    }
                    else if(hour >= 3){
                        color = 0x7C3E00
                        rank = "Brown"
                    }
                    else{
                        color = 0xD9D9D9
                        rank = "Gray"
                    }
                    let dt = new Date();
                    let date = [0,0,0,0,0,0,0];
                    let month = [0,0,0,0,0,0,0];
                    dt.setDate(dt.getDate()+1)
                    for(let i=0;i<7;i++){
                        dt.setDate(dt.getDate()-1);
                        month[i] = dt.getMonth()+1;
                        date[i] = dt.getDate();
                    }

まずは.reduceを使って、studyの合計、つまり週の合計勉強時間を計算してhourに代入します。この時単位はミリ秒なので、3600で割って単位を時間にします。それを使って今度はrankとcolorを決めます。hourの値に合わせて、ランクのカラーコードをcolorに、ランクの色名をrankに代入します。

次に、過去7日の日付を取得します。dt = new date()でdateコンストラクターを呼び出して初期化します。また、7日間の月と日にちを記録するために配列dateとmonthも用意します。また、この後のfor文の処理で最初に1日前の日付にするので、先に1日後の日付に直します。dt.setDate(dt.getDate()+n)でn日ずらすことができます。getDate()で日にちを取得し、それにn日足した日付でsetDate()で整形するという処理です。

for文で7回ループを回し、今日〜7日前までの日付を取得します。まずは1日前のデータに戻すためにdt.setDate(dt.getDate()-1)を実行します。その次に、monthのi番目をdt.getMonth+1にします。getMonthで月を取得できるのですが、1月は0、12月は11が返ってくる謎仕様なので、+1をしています。続いて、dateのi番目をdt.getDate()にします。monthと違ってdateは1日が1、31日が31を返すので、そのまま代入します。
このfor文が7回ループすれば、month[0]date[0]日が今日の日付、month[6]date[6]日が7日前の日付になります。

studyCommands.js
                    embed = new EmbedBuilder()
                        .setColor(color)
                        .setTitle(user.name + 'の自習室データ')
                        .setAuthor({
                            name: "StudyRoom BOT",
                            iconURL: 'https://cdn.discordapp.com/avatars/1046304160448000072/aa9294120328c547950c7d95f01435c9.webp?size=320',
                            url: 'https://discord.com/invite/fpEjBHTAqy'
                        })
                        .setThumbnail(user.icon)
                        .setDescription("現在のランク:" + rank)
                        .addFields(
                            {
                                name:"過去7日間の毎日のデータ",
                                value:month[6] + '/' + date[6] + "    " + Math.floor(user.study[6]/360)/10 + "時間\n" +month[5] + '/' + date[5] + "    " + Math.floor(user.study[5]/360)/10 + "時間\n" + month[4] + '/' + date[4] + "    " + Math.floor(user.study[4]/360)/10 + "時間\n" + month[3] + '/' + date[3] + "    " + Math.floor(user.study[3]/360)/10 + "時間\n" + month[2] + '/' + date[2] + "    " + Math.floor(user.study[2]/360)/10 + "時間\n" + month[1] + '/' + date[1] + "    " + Math.floor(user.study[1]/360)/10 + "時間\n" + month[0] + '/' + date[0] + "    " + Math.floor(user.study[0]/360)/10 + "時間\n"
                            },
                            {
                                name:"過去7日間の勉強時間",
                                value:Math.floor(hour*10)/10 + "時間"
                            },
                            {
                                name:"累計勉強時間",
                                value:Math.floor(user.StudyAll/360)/10 + "時間"
                            }
                        )
                        .setTimestamp()
                        .setFooter({ text: 'Developed by NITKC-22DEV' ,iconURL: 'https://avatars.githubusercontent.com/u/107338867?s=200&v=4'});
                }
                await interaction.reply({ embeds: [embed] });
            },
        },

最後に、埋め込みメセージを作って送信していきます。

  • Color:ランクの色を指定します。color変数に入れてあるのでそれを指定します。
  • Title:〇〇の自習データ となるようにuser.nameを使って指定します。
  • Thumbnail:ユーザーのアイコンの画像を入れます。user.iconがアイコンのURLになっているので指定します。
  • Description:現在のランクを入れます。rankに入っているため、`"現在のランク:" + rank"で表示します。

以下はフィールドの内容です。

  • 過去7日間の毎日のデータ
    month[n] + '/' + date[n] + " " + Math.floor(user.study[n]/360)/10 + "時間\n"をnが6~0の時分書きます。month[n]'/'date[n]で日付の形が1/1のように表示できるので表示した後、勉強時間を表示します。
    データは秒単位で入っていますが、単純に3600で割ると小数点以下が長くなってしまうので、小数第一までを表示します。まず360で割った後、Math.floorで小数点以下を切り捨てます。その後10で割ることで、小数第一までが残ります。最後に、改行コードである\nを入れます。これを7日分続けます。

  • 過去7日間の累計勉強時間
    hourにすでに累計勉強時間が入っているので、先程と同じように小数第一までを表示します。

  • 累計勉強時間
    user.studyAllに秒単位で入っているので、先程と同じように小数第一まで表示します。

これで埋め込みメッセージは終わりです。後は、await interaction.reply({ embeds: [embed] });で送信して完了です。userがundefinedのときもembed変数に入っているので、elseの外で送信しています。

studyweek

これも、おおよそstudydateと同じような仕組みになっています。

studyCommands.js
{
            data: new SlashCommandBuilder()
                .setName('studyweek')
                .setDescription('自習室の週別データを表示します')
                .addUserOption(option =>
                    option
                        .setName('ユーザー')
                        .setDescription('記録を見たいユーザーを指定します')
                        .setRequired(true)
                ),
            async execute(interaction) {
                //jsonの読み込みとユーザーデータの取り出し
                const date = JSON.parse(fs.readFileSync('./studyroom.json', 'utf8'));
                let option = interaction.options.data[0].value;
                let user=date.date.find(date => date.uid === option);
                let embed;
                if(user === undefined){
                    embed = new EmbedBuilder()
                        .setColor(0xD9D9D9)
                        .setTitle('データが見つかりません')
                        .setAuthor({
                            name: "StudyRoom BOT",
                            iconURL: 'https://cdn.discordapp.com/avatars/1046304160448000072/aa9294120328c547950c7d95f01435c9.webp?size=320',
                            url: 'https://discord.com/invite/fpEjBHTAqy'
                        })
                        .setDescription('データが存在しないか、破損しています。このBOTが追加されたばかりの場合は、データが作成中の可能性があります。そうでない場合、VCに参加してからもう一度試してください。それでもエラーが起きる場合は、管理者に連絡してください。')
                        .setTimestamp()
                        .setFooter({ text: 'Developed by NITKC-22DEV' ,iconURL: 'https://avatars.githubusercontent.com/u/107338867?s=200&v=4'});
                }
                else{
                    let hour = user.study.reduce((sum, element) => sum + element, 0)/3600;
                    let color;
                    let rank;
                    if(hour >= 48){
                        color = 0x6DBCD1
                        rank = "Platinum";
                    }
                    else if(hour >= 42){
                        color = 0xFFEB99
                        rank = "Gold";
                    }
                    else if(hour >= 35){
                        color = 0xF00400
                        rank = "Red";
                    }
                    else if(hour >= 24){
                        color = 0xF47A00
                        rank = "Orange"
                    }
                    else if(hour >= 20){
                        color = 0xBCBC00
                        rank = "Yellow"
                    }
                    else if(hour >= 14){
                        color = 0x0000F4
                        rank = "Blue"
                    }
                    else if(hour >= 10){
                        color = 0x00B5F7
                        rank = "Light Blue"
                    }
                    else if(hour >= 7){
                        color = 0x007B00
                        rank = "Green"
                    }
                    else if(hour >= 3){
                        color = 0x7C3E00
                        rank = "Brown"
                    }
                    else{
                        color = 0xD9D9D9
                        rank = "Gray"
                    }
                    let dt = new Date();
                    let dayofweek = dt.getDay();
                    let week = 0;
                    if(dayofweek === 0)dayofweek = 7;
                    for(let i=0;i<dayofweek;i++){ //今日分まで足し算して今週の時刻計算
                        week += user.study[i]
                    }
                    let All =Math.floor(week/360)/10 + Math.floor(user.StudyWeek[0]/360)/10 + Math.floor(user.StudyWeek[1]/360)/10 + Math.floor(user.StudyWeek[2]/360)/10 + Math.floor(user.StudyWeek[3]/360)/10
                    embed = new EmbedBuilder()
                        .setColor(color)
                        .setTitle(user.name + 'の自習室データ')
                        .setAuthor({
                            name: "StudyRoom BOT",
                            iconURL: 'https://cdn.discordapp.com/avatars/1046304160448000072/aa9294120328c547950c7d95f01435c9.webp?size=320',
                            url: 'https://discord.com/invite/fpEjBHTAqy'
                        })
                        .setThumbnail(user.icon)
                        .setDescription("現在のランク:" + rank + "\n")
                        .addFields(
                            {
                                name:"直近5週間の勉強時間",
                                value:"今 週 :" + Math.floor(week/360)/10 + "時間\n先 週 :" + Math.floor(user.StudyWeek[0]/360)/10 + "時間\n2週前:" + Math.floor(user.StudyWeek[1]/360)/10 + "時間\n3週前:" + Math.floor(user.StudyWeek[2]/360)/10 + "時間\n4週前:" + Math.floor(user.StudyWeek[3]/360)/10 + "時間\n",
                            },
                            {
                                name:"直近5週間の平均勉強時間",
                                value:"1日:" + (Math.floor(All/5/7*10)/10) + "時間\n1週間:" + (Math.floor(All/5*10)/10) + "時間"
                            },
                            {
                                name:"累計勉強時間",
                                value:Math.floor(user.StudyAll/360)/10 + "時間"
                            }
                        )
                        .setTimestamp()
                        .setFooter({ text: 'Developed by NITKC-22DEV' ,iconURL: 'https://avatars.githubusercontent.com/u/107338867?s=200&v=4'});
                }
                await interaction.reply({ embeds: [embed] });
            },

ただし、少し違う部分があります。
まずは、以下の部分

studyCommands.js
                    let dt = new Date();
                    let dayofweek = dt.getDay();
                    let week = 0;
                    if(dayofweek === 0)dayofweek = 7;
                    for(let i=0;i<dayofweek;i++){ //今日分まで足し算して今週の時刻計算
                        week += user.study[i]
                    }
                    let All =Math.floor(week/360)/10 + Math.floor(user.StudyWeek[0]/360)/10 + Math.floor(user.StudyWeek[1]/360)/10 + Math.floor(user.StudyWeek[2]/360)/10 + Math.floor(user.StudyWeek[3]/360)/10

この部分では、月曜日〜その日までの曜日の合計勉強時間を出しています。例えば、月曜日なら月曜日の勉強時間、水曜日なら月~水の勉強時間となります。getDay()で曜日が取得できますが、それは日曜日が0、土曜日が6となっています。そのため、基本的には.getDay()日分の毎日のデータを足せばよいのですが、日曜日は0日分になってしまうので、その場合は7にする処理を先に入れています。

そして、最後に平均勉強時間を出すための、5週間の勉強時間をAllに入れます。user.studyWeek[n]でn+1週間前の合計を見ることができます。それを、小数第一位までにするために先程と同じ方法を使って計算した後合計しています。

また、埋め込みメッセージも合わせて変更されています。が、結構そのままなので説明は省略します。

joinFunc.js

ここには、ユーザーが参加・退出したり、BOTがギルドに参加したり退出したときに行う処理を書くファイルです。
BOT参加時にはbot関数、ユーザー参加時にはuser関数、BOTやユーザーの退出時にはrmuser関数が実行される仕組みになっています。

まずはいつものおまじない

joinFunc.js

const { Client, GatewayIntentBits, Partials, EmbedBuilder} = require('discord.js');
const fs = require('fs');
const client = new Client({
    intents: [
        GatewayIntentBits.Guilds,
        GatewayIntentBits.GuildVoiceStates,
        GatewayIntentBits.GuildMembers,
    ],
    partials: [Partials.Channel],
});
const token = require('../config.json')

bot関数

joinFunc.js
exports.bot = async function join(guild){
    let config = JSON.parse(fs.readFileSync('./config.json', 'utf8'));

    let role=config.role.find(item => item.guild === guild.id);
    const newRole = await guild.roles.create({
        name: 'Studying now',
        color: 0x00A0EA,
        reason: "StudyRoom BOTの操作により作成"
    });
    if(role === undefined){
        config.role.push({
            guild:guild.id,
            id:newRole.id
        })
    }
    else{
        let point = config.role.indexOf(role)
        config.role[point]={
            guild:guild.id,
            id:newRole.id
        }
    }
    fs.writeFileSync('./config.json', JSON.stringify(config,null ,"\t"));
    //自習室データ作成・更新
    const guildMember = await guild.members.fetch()
    let date = JSON.parse(fs.readFileSync('./studyroom.json', 'utf8'));

bot関数の前半では、studying nowロールの作成をを行います。引数はguild変数に入れてあり、参加したギルドの情報が入っています。

いつもどおりconfig.jsonを書き込み可能な形式で読み込み、guildIDが一致するデータをroleから探してきます。見つからなかった場合はundefinedになります。
その後、ロールを作成します。await guild.roles.create()にオブジェクト型を渡してあげることでそのギルドで作成できるので、「Studying now」ロールを色0x00A0EAにしました。

もしroleがundefinedなら、そのサーバーに関するデータはないのでconfig.roleに{guild:ギルドID,id:ロールID}を追加します。
そうでなければそのギルドに関する情報は持っているので、先ほど作成したロールで更新します。作ったロールのidは、newRole.idで取得できます。終わったら、config.jsonに書き込みます。

joinFunc.js
//自習室データ作成・更新
    const guildMember = await guild.members.fetch()
    let date = JSON.parse(fs.readFileSync('./studyroom.json', 'utf8'));


    for(let i=0;i<guild.memberCount;i++){
        if(guildMember.at(i).user.bot === false){
            let userDate=await client.users.fetch(guildMember.at(i).user.id);
            let username = userDate.username;
            let discriminator=userDate.discriminator;
            let icon = userDate.displayAvatarURL()

            let user=date.date.find(date => date.uid === guildMember.at(i).user.id);
            let userPoint = date.date.indexOf(user)
            if(user === undefined){
                date.date.push({
                    "uid": guildMember.at(i).user.id,
                    "name": username + '#' + discriminator,
                    "icon":icon,
                    "lastJoin": 0,
                    "study":[0,0,0,0,0,0,0],
                    "StudyAll": 0,
                    "task":[0,0,0,0,0,0,0],
                    "TaskAll": 0,
                    "now": false,
                    "StudyWeek": [0,0,0,0],
                    "TaskWeek": [0,0,0,0],
                    "guild":[guild.id]
                })
            }
            else{
                if(date.date.at(userPoint).guild.includes(guild.id) === false){
                    date.date.at(userPoint).guild.push(guild.id)
                }
                date.date.at(userPoint).name = username + '#' + discriminator;//ついでにアイコンとか更新
                date.date.at(userPoint).icon = icon;
            }
            fs.writeFileSync('./studyroom.json', JSON.stringify(date,null ,"\t")); //json書き出し
        }
    }

}

bot関数の後半では、現在サーバーに参加している人のデータの更新・作成を行います。

guild.member.fethc()でギルドメンバーを取得し、studyroom.jsonを書き込み可能な形式で読み込みます。
そして、guild.memberCount回ループを回してメンバー全員の処理を行っていきます。(guild.memberCountでギルドの人数は取得できます)

まず、guildMember.at(i).user.botでユーザーがbotかどうかを判定します。botに関してはデータを作成する必要がないためです。
botではなかった場合、以下の変数を用意します。なお、ユーザーidはguildMember.at(i).user.id)で取得します。

変数 中身 取得方法
userDate ユーザーの情報 await client.users.fetch(ユーザーid)
username ユーザー名 userDate.username
discriminator #〇〇〇〇 の数字 userDate.discriminator
icon アイコンのURL userDate.displayAvatarURL()
user studyroom.jsonのデータ date.date.find(date => date.uid ===ユーザーid
userPoint sturyroom.jsonでのデータの位置 date.date.indexOf(user)

もしその人のデータが作成されていたらuserがundefinedになるので、以下の新規データを追加します。

{
    "uid": guildMember.at(i).user.id,
    "name": username + '#' + discriminator,
    "icon":icon,
    "lastJoin": 0,
    "study":[0,0,0,0,0,0,0],
    "StudyAll": 0,
    "task":[0,0,0,0,0,0,0],
    "TaskAll": 0,
    "now": false,
    "StudyWeek": [0,0,0,0],
    "TaskWeek": [0,0,0,0],
    "guild":[guild.id]
}

作成されていなかった場合は、date.date.at(userPoint).guild.includes(guild.id) === falseでギルドIDがすでにguildに追加されていなか確認した上で、追加されていなかったらguildにギルドIDを追加します。ついでに、

joinFunc.js
date.date.at(userPoint).name = username + '#' + discriminator;//ついでにアイコンとか更新
date.date.at(userPoint).icon = icon;

でユーザー名とアイコンを更新しておきます。変更していたとしてもこの時点で反映されるってわけです。

user関数

ユーザーが新しく入ってきたときに実行される関数です。bot関数とほとんど同じ処理になります。引数は入ってきた人のユーザーデータで、guild変数に入れてあります(使いまわしてるのがバレる...)

joinFunc.js
exports.user = async function newUser(guild){
    let date = JSON.parse(fs.readFileSync('./studyroom.json', 'utf8'));
    let config = JSON.parse(fs.readFileSync('./config.json', 'utf8'));

    if(guild.user.bot === false){
        let userDate=await client.users.fetch(guild.user.id);
        let username = userDate.username;
        let discriminator=userDate.discriminator;
        let icon = userDate.displayAvatarURL()

        let user=date.date.find(date => date.uid === guild.user.id);
        let userPoint = date.date.indexOf(user)
        if(user === undefined){
            date.date.push({
                "uid": guild.user.id,
                "name": username + '#' + discriminator,
                "icon":icon,
                "lastJoin": 0,
                "study":[0,0,0,0,0,0,0],
                "StudyAll": 0,
                "task":[0,0,0,0,0,0,0],
                "TaskAll": 0,
                "now": false,
                "StudyWeek": [0,0,0,0],
                "TaskWeek": [0,0,0,0],
                "guild":[guild.id]
            })
        }
        else{
            if(date.date.at(userPoint).guild.includes(guild.id) === false){
                date.date.at(userPoint).guild.push(guild.id)
            }
            date.date.at(userPoint).name = username + '#' + discriminator;//ついでにアイコンとか更新
            date.date.at(userPoint).icon = icon;
        }
        fs.writeFileSync('./studyroom.json', JSON.stringify(date,null ,"\t")); //json書き出し
    }
}

bot関数との違いは、一人のみ更新であることです。for文の中身とほとんど同じであり、また使いまわしているので引数の変数もguildのままです。

rmuser関数

ユーザーが抜けたりサーバーが抜けたときに発動する関数です。

joinFunc.js
exports.rmuser = async function rmUser(guild){
    let date = JSON.parse(fs.readFileSync('./studyroom.json', 'utf8'));
    let config = JSON.parse(fs.readFileSync('./config.json', 'utf8'));

    if(guild.user.bot === false){
        let userDate=await client.users.fetch(guild.user.id);
        let username = userDate.username;
        let discriminator=userDate.discriminator;
        let icon = userDate.displayAvatarURL()
        let userPoint = date.date.indexOf(date.date.find(date => date.uid === guild.user.id))
        if(userPoint === undefined){

        }
        else{
            await date.date.at(userPoint).guild.splice(date.date.at(userPoint).guild.indexOf(guild.id),1);
            date.date.at(userPoint).name = username + '#' + discriminator;//ついでにアイコンとか更新
            date.date.at(userPoint).icon = icon;
        }
        fs.writeFileSync('./studyroom.json', JSON.stringify(date,null ,"\t")); //json書き出し
    }
}

追加だった部分を、代わりにspliceを使って削除にしています。ただしこの関数にはバグがおそらく存在します。サーバー脱退したときの処理が行われていません。しかしsturyroom.jsのほうで工夫をすることで、このバグを直しています。

studyroom.js

このファイルでは、VCに出入りしたときの処理を行うfunc関数と、日付をまたいだときに更新処理を行うupdate関数の2つを定義しています。

studyroom.js
const { Client, GatewayIntentBits, Partials,GuildMember} = require('discord.js');
const fs = require('fs');
const client = new Client({
    intents: [
        GatewayIntentBits.Guilds,
        GatewayIntentBits.GuildVoiceStates,
        GatewayIntentBits.GuildMembers,
        GatewayIntentBits.GuildMessages,
        GatewayIntentBits.GuildMessageReactions,
        GatewayIntentBits.MessageContent,

    ],
    partials: [Partials.Channel],
});

const token = require('../config.json')
const {underscore} = require("@discordjs/builders");

これはいつものおまじない

func関数

joinFunc.jsのbot関数などと同じような処理も一部入っています。これは、データが万が一存在しなかった場合にVCに入るとデータが作成され、存在した場合もアイコンと名前を更新する処理を行っているからです。
引数はoldStateとnewStateの2つで、oldStateがVC参加前のその人のボイスチャットの状態、newStateがVC参加後のその人のボイスチャットの状態です。

studyroom.js
exports.func = async function studyroom(oldState, newState){
    const date = JSON.parse(fs.readFileSync('./studyroom.json', 'utf8'));
    let config = JSON.parse(fs.readFileSync('./config.json', 'utf8'));
    let time = new Date();
    let UNIX=time.getTime()/1000; //UNIXTime
    let user=date.date.find(date => date.uid === oldState.id); /*その人のデータ*/
    if(user === undefined){
        date.date.push({
                "uid": "",
                "name": "",
                "icon":"",
                "lastJoin": 0,
                "study":[0,0,0,0,0,0,0],
                "StudyAll": 0,
                "task":[0,0,0,0,0,0,0],
                "TaskAll": 0,
                "now": false,
                "StudyWeek": [0,0,0,0],
                "TaskWeek": [0,0,0,0],
                "guild":[oldState.guild.id]
            })
        date.date[date.date.length - 1].uid = String(newState.id); //id取得
        user=date.date[date.date.length - 1];

    }
    //名前とアイコンの更新
    let userDate=await client.users.fetch(newState.id);
    let username = userDate.username;
    let discriminator=userDate.discriminator;
    let icon = userDate.displayAvatarURL()
    let members =
    user.name = username + '#' + discriminator;
    user.icon = icon;


    let userPoint = date.date.indexOf(user)

前半部分では、現在時刻の取得とデータの更新を行っています。データの更新はbot関数と同じですが、少し昔に実装したせいで少しだけわかりにくい実装になっている部分があります(uidを後から追加している)
また、その後もuser変数は使うので、データを新規作成した場合にはuserを再定義しています。

現在時刻は、UNIX TIMEを.getTime()/1000で取得しています。getTime単体だとミリ秒になり少し扱いにくいので、秒単位にしています。

studyroom.js
    if(oldState.channel===null){
        if(config.studyVC.indexOf(newState.channelId)!==-1){
            console.log(user.name+" join VC");
            user.lastJoin = UNIX; //参加した時刻を書き込み
            user.now = true;
            for(let i=0;i<user.guild.length;i++){
                let role = config.role.find(date => date.guild === user.guild.at(i));
                let guild = client.guilds.cache.get(user.guild.at(i)) ?? await client.guilds.fetch(user.guild.at(i));
                if(guild === undefined || guild === null){
                    user.guild.splice(i,1)
                    let index = config.role.indexOf(role)
                    config.role.splice(index,1)
                }
                else{
                    let guildroles = guild.roles.cache.find(date => date.id === role.id) ?? await guild.roles.fetch(role.id)

                    if(guildroles === undefined || guildroles === null){
                        const newRole = await guild.roles.create({
                            name: 'Studying now',
                            color: 0x00A0EA,
                            reason: "StudyRoom BOTの操作により作成"
                        });
                        let index = config.role.indexOf(config.role.find(date => date.guild === guild.id))
                        config.role.at(index).id = newRole.id
                    }
                    await guild.members.addRole({
                        user: newState.id,
                        role: role.id
                    })
                }
            }
        }
    }
   

次に、VCの操作をしていきます。前提として、VCのステータスのよってどのような状態を表しているかを知る必要があります。

oldState.channel newState.channel 状態
null チャンネルID VCに参加
チャンネルID null VCから離脱
チャンネルID チャンネルID VCの変更やミュート等

これを用いて、離脱や参加の処理を行っていきます。だいたいどれも同じような処理になるので、まずは参加の処理を丁寧に解説します。

もしoldState.channnelがnullであれば参加であるので処理を始めます。ここで、config.studyVCの中でのチャンネルIDの場所を確認します。存在しない場合、つまり自習室に追加されていない場合は-1を返されるので何もしないでスルーします。
自習室に参加したことがわかったら、studyroom.jsonのlastjoinを現在時刻に、nowをtrueに変更します。

その後、ロールを付与していきます。user.guildにそのユーザーが参加しているギルド一覧があるので、そのサーバーすべてのロールidをconfig.jsonから取得して付与していきます。
user.guild.length回ループを回して、config.roleからギルドIDと一致するものをroleに入れます。
また、guildにはclient.guilds.cache.get(user.guild.at(i))またはawait client.guilds.fetch(user.guild.at(i))のどちらかを入れます。cacheを使うとundefinedになるときがあるので、そのときは??があるのでfetchのほうが使われます。

もしguildがそれでもundefinedやnullだった場合は、先程述べたユーザー退出処理のバグが原因でギルドの情報だけが残っていて、サーバーにはすでに参加していないということです。そのため、user.guildやconfig.roleからそのデータを削除します。

そうでなければ、guild.role.cacheまたはguild.role.fetchを使ってroleidからロールを取得します。もしもそのロールが存在しなければ、ロールの新規作成処理をします。

そして、guild.member.addRole()にオブジェクト型を渡してロールを付与します。これをすべてのギルドで行います。

studyroom.js
    else if(newState.channel===null){
        if(config.studyVC.indexOf(oldState.channelId)!==-1){
            user.StudyAll += UNIX-user.lastJoin;
            user.study[0] += UNIX-user.lastJoin;
            console.log(user.name+" leave VC");
            user.now = false;

            for(let i=0;i<user.guild.length;i++){
                let role = config.role.find(date => date.guild === user.guild.at(i));
                let guild = client.guilds.cache.get(user.guild.at(i)) ?? await client.guilds.fetch(user.guild.at(i));
                if(guild === undefined || guild === null){
                    user.guild.splice(i,1)
                    let index = config.role.indexOf(role)
                    config.role.splice(index,1)
                }
                else{
                    let guildroles = guild.roles.cache.find(date => date.id === role.id) ?? await guild.roles.fetch(role.id)

                    if(guildroles === undefined || guildroles === null){
                        const newRole = await guild.roles.create({
                            name: 'Studying now',
                            color: 0x00A0EA,
                            reason: "StudyRoom BOTの操作により作成"
                        });
                        let index = config.role.indexOf(config.role.find(date => date.guild === guild.id))
                        config.role.at(index).id = newRole.id
                    }
                    await guild.members.removeRole({
                        user: newState.id,
                        role: role.id
                    })
                }
            }
        }
        user.now = false
    }

こんどはVCから離脱したときの操作です。違いは、勉強時間の書き込みがあるところとロールが追加ではなく消す処理になることです。

勉強時間の書き込みは以下のコードで行います。

studyroom.js
            user.StudyAll += UNIX-user.lastJoin;
            user.study[0] += UNIX-user.lastJoin;
            console.log(user.name+" leave VC");
            user.now = false;

現在の時刻から、最後に入った時間を引いて足します。また、user.nowをfalseに変えます。

また、ロールを追加ではなく消す処理にするときは、guild.members.removeRole()になります。

studyroom.js
    else{
        if(config.studyVC.indexOf(oldState.channelId)!==-1){
            user.StudyAll += UNIX-user.lastJoin;
            user.study[0] += UNIX-user.lastJoin;
            user.now = false;

            for(let i=0;i<user.guild.length;i++){
                let role = config.role.find(date => date.guild === user.guild.at(i));
                let guild = client.guilds.cache.get(user.guild.at(i)) ?? await client.guilds.fetch(user.guild.at(i));
                if(guild === undefined || guild === null){
                    user.guild.splice(i,1)
                    let index = config.role.indexOf(role)
                    config.role.splice(index,1)
                }
                else{
                    let guildroles = guild.roles.cache.find(date => date.id === role.id) ?? await guild.roles.fetch(role.id)

                    if(guildroles === undefined || guildroles === null){
                        const newRole = await guild.roles.create({
                            name: 'Studying now',
                            color: 0x00A0EA,
                            reason: "StudyRoom BOTの操作により作成"
                        });
                        let index = config.role.indexOf(config.role.find(date => date.guild === guild.id))
                        config.role.at(index).id = newRole.id
                    }
                    await guild.members.removeRole({
                        user: newState.id,
                        role: role.id
                    })
                }
            }
        }
        if(config.studyVC.indexOf(newState.channelId)!==-1){
            user.lastJoin = UNIX; //参加した時刻を書き込み
            user.now = true;

            for(let i=0;i<user.guild.length;i++){
                let role = config.role.find(date => date.guild === user.guild.at(i));
                let guild = client.guilds.cache.get(user.guild.at(i)) ?? await client.guilds.fetch(user.guild.at(i));
                if(guild === undefined || guild === null){
                    user.guild.splice(i,1)
                    let index = config.role.indexOf(role)
                    config.role.splice(index,1)
                }
                else{
                    let guildroles = guild.roles.cache.find(date => date.id === role.id) ?? await guild.roles.fetch(role.id)

                    if(guildroles === undefined || guildroles === null){
                        const newRole = await guild.roles.create({
                            name: 'Studying now',
                            color: 0x00A0EA,
                            reason: "StudyRoom BOTの操作により作成"
                        });
                        let index = config.role.indexOf(config.role.find(date => date.guild === guild.id))
                        config.role.at(index).id = newRole.id
                    }
                    await guild.members.addRole({
                        user: newState.id,
                        role: role.id
                    })
                }
            }
        }
        console.log(user.name+" change VC");
    }
    date.date[userPoint]=user
    fs.writeFileSync('./studyroom.json', JSON.stringify(date,null ,"\t")); //json書き出し
    fs.writeFileSync('./config.json', JSON.stringify(config,null ,"\t"));
}

これは、以下の状態のときの処理のコードです。

oldState.channel newState.channel 状態
チャンネルID チャンネルID VCの変更やミュート等

この場合

  • どちらも自習室 → 切断処理をして参加処理
  • oldだけ自習室 → 切断処理だけする
  • newだけ自習室 → 参加処理だけする

ことで、適切に記録することができます。それをそのまま素直に実装したコードです。仕組みは上の方のコードと同じです。

最後に、studyroom.jsonとconfig.jsonに描き込んだら処理は終了です。

update関数

この関数では、以下の処理を行います

  • 日付をまたぐ処理
  • 日付のデータをずらす処理
  • 週のデータをずらす処理
    日付をまたぐ処理をしないと、例えば1/1の0時から1/2の1時まで参加していると、25時間という記録になってしまいます。それを防ぐために、0時に一旦切断してまた接続したということにすることで防いでいます。
    また、日付や週のデータは相対的な日数でカウントしているため、そのあたりも更新してあげる必要があります。
studyroom.js
exports.update = function (){
    const date = JSON.parse(fs.readFileSync('./studyroom.json', 'utf8'));
    let config = JSON.parse(fs.readFileSync('./config.json', 'utf8'));
    /*0時に切断したことにする*/
    let time = new Date();
    let UNIX=time.getTime()/1000; //UNIXTime
    UNIX=UNIX-(UNIX%86400)-32400+86400; //今日の0時

    let now = date.date.filter(function(item, index){ /*今入ってる人を列挙*/
        if (item.now === true ) return true;
    });
    for(let i=0; i<now.length; i++){
        let user = now[i]; /*その人のデータ*/
        let userPoint = date.date.indexOf(user) /*その人のデータの位置*/
        /*切断と同様の処理*/
        user.StudyAll += UNIX-user.lastJoin;
        user.study[0] += UNIX-user.lastJoin;
        /*参加と同じ処理*/
        console.log(user.name+"さんがVCに入ったまま日付をまたぎました!");
        user.lastJoin = UNIX;
        date.date[userPoint]=user
    }

UNIX-(UNIX%86400)-32400+86400を行うことで、その日の12時の時刻を取得できます。1日は86400秒なので、それで割ったあまりをUNIXから引くことで割り切れます。また、時差を考えて-32400を引いた上で、切り捨てしているため1日前の時刻になっているので86400足す という仕組みになっています。

そして、.filterを使ってnowがtureの人、つまり現在VCに参加している人のidをnow変数に入れます。配列になっています。
nowの各要素に対して、その人のデータを取得し、切断→参加と同じ処理を行います。

studyroom.js
    let dt = new Date();
    let dayofweek = dt.getDay();
    if (dayofweek === ) {
        for(let i=0;i<date.date.length;i++){
            for(let j=4;j>0;j--){
                date.date[i].studyWeek[j] = date.date[i].studyWeek[j-1];
            }
            date.date[i].studyWeek[0] = date.date[i].study.reduce((sum, element) => sum + element, 0);;
        }
    }

これは、曜日のデータをずらす作業です。もし月曜日なのであれば、すべての要素を一つ後ろにずらします。また、直近7日間はまだ先週月曜〜日曜までになっているが、先週日曜のデータはすでに集計済みなので、その合計を最新の要素に入れてあげます。

studyroom.js
    for(let i=0;i<date.date.length;i++){
        for(let j=6;j>0;j--){
            date.date[i].study[j] = date.date[i].study[j-1];
        }
        date.date[i].study[0] = 0;
    }
    for(let i=0;i<date.date.length;i++){
        for(let j=6;j>0;j--){
            date.date[i].task[j] = date.date[i].task[j-1];
        }
        date.date[i].task[0] = 0;
    }
    fs.writeFileSync('./studyroom.json', JSON.stringify(date,null ,"\t")); //json書き出し
}

client.login(token.token);

同じように、studyとtaskに関してもずらし、最新の要素には0を入れます。そして、最後に描き込んで関数は終了です。最後におまけのログインを書いておきます。

このBOTの問題点

  • studyroom.jsonが非常に長くなる
    一人あたりおよそ40行であり、この記事を書いている時点で8000行近くある。
  • 処理に時間がかかる
    jsonを使用しているため、どうしても書き込みに時間がかかってしまう。特に、bot関数はサーバーの人数/50分かかってしまう。
    →その間に自習室に入るとエラーの可能性

このBOTの今後

  • DBに移行したい
  • 全体的に実装が適当なので、改善したい
  • 想定できてないところでエラーが起きそうなので直したい

招待リンク

上記の問題点等により、いつデータがなくなってもおかしくないですが、それでもいいよ! って場合はこちらから招待してください。

読みにくい文章を最後まで読んでくださりありがとうございました。

12
1
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
12
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?