Discordで学習記録をつけるBOTを作成してみた

エンジニアコミュニティサイト「TechCommit」で作業ログをつける習慣をつけたく、自分の学習記録を残すため、作業時間を計測、かつ作業ログを記録できるBOTが欲しくなり、色々調べながら作成してみました。

やはり、毎回、エラーに悩まされるのはいい思い出でした。

:::note info
追記 学習ボタンを押したらDisabledにする　記事に追加　(2024/2/27) 
:::


## デモ

こんな感じのフォーマットを作ったり、

学習目標設定
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3304752/4d9a4bcc-22cc-dbb9-cf2d-ae36229aefde.png)

学習記録報告
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3304752/7eaea70e-c59f-3b96-a757-8735bd1ff7d8.png)

学習経過の記録をdiscord内のチャットに送信ができます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3304752/2cb21945-430e-8851-4685-90a14d950565.png)

### 動画はこちら

<iframe width="560" height="315" src="https://www.youtube.com/embed/NIgjPiDH9M4" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" loading="lazy" allowfullscreen></iframe>

## 事前準備

今回はjsで作成しました。
エディターはVSCODEがおすすめです。
Node.jsが必要になってきます。

[サルでもわかる Discord Botの作り方](https://narikakun.net/tag/dis-saru/)

恐らく初心者でもわかりやすいのですが、バージョンが古いため、BOTを作成する際は設定が異なってくるため、次のBOTの作り方で紹介されている記事などを参考にして開発環境とBOTを用意します。

## BOTの作り方

[Botアカウント作成](https://discordpy.readthedocs.io/ja/latest/discord.html)

こちらを参考に作りました。

トークンはBOTタグの「Reset Token」を押します。

パーミッションについてはこちらの記事を参考にしてください。

[Discord DeveloperのPermissions 日本語まとめ【開発者向け】](https://narikakun.net/technology/discord-developer-permissions/)


## Privileged Gateway Intentsはすべてチェック

[サルでもわかる Discord Botの作り方④ ～メンバーが参加したら通知するBot～](https://narikakun.net/technology/dis-saru-4/)

上記の記事を参考にPrivileged Gateway Intentsをすべてチェックし、作業ディレクトリを作成します。

[Discord.py の Intents について](https://qiita.com/Pumila/items/8b8e14f60fd7f266872b)

[サルでもわかる Discord Botの作り方④ ～メンバーが参加したら通知するBot～](https://narikakun.net/technology/dis-saru-4/)の記事にも書いてますが、

`npm install discord.js`をターミナルもしくはコマンドプロンプトなどで入力し、ライブラリを追加します。

そして、作業ディレクトリに`index.js`を追加し、下記のコードを追加すると完成です。

実行したい場合、`node index.js`をプロジェクトフォルダの場所で叩けば実行して確認することができます。

※　discordは開発者モードを有効にして、使用したいチャンネルのIDを取得するにはそのチャンネルをdiscord上で右クリックしてIDを取得することが可能です。

トークンは、Botを作成する際に作ったものです。

## コード

```index.js
const {
	Client,
	GatewayIntentBits,
	Partials,
	ButtonStyle,
	ButtonBuilder,
	ModalBuilder,
	TextInputBuilder,
	TextInputStyle,
	Events,
	ActionRowBuilder,
} = require('discord.js');

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

const textChannelId =  '1234567890123456789'; // 送信したいテキストチャンネルのID
const studyChannelId = '1234567890123456789'; // 学習記録用のボイスチャンネルID
let sessionStartTimes = {}; // ユーザーが勉強を始めた時間を記録

client.once('ready', () => {
	console.log(`Logged in as ${client.user.tag}!`);
});

client.on('voiceStateUpdate', async (oldState, newState) => {
	const textChannel = await client.channels.fetch(textChannelId);
	if (
		newState.channelId === studyChannelId &&
		oldState.channelId !== studyChannelId
	) {
		// ユーザーが学習記録ボイスチャンネルに参加
		const row = new ButtonBuilder()
			.setCustomId('start_study')
			.setLabel('学習を開始する')
			.setStyle(ButtonStyle.Primary);

		const rowComponents = new ActionRowBuilder().addComponents(row);

		await textChannel.send({
			content: `${newState.member.user.username}さんが、学習を開始しました。${newState.member.user.username}さんの学習目標を設定してください`,
			components: [rowComponents],
		});
		sessionStartTimes[newState.id] = new Date(); // 学習開始時間を記録
	} else if (
		oldState.channelId === studyChannelId &&
		newState.channelId !== studyChannelId
	) {
		// ユーザーが学習記録ボイスチャンネルから退出

		sessionEndTime = new Date();
		sessionStartTime = sessionStartTimes[oldState.id];

		duration = sessionEndTime - sessionStartTime;
		delete sessionStartTimes[oldState.id]; // セッション記録を削除

		const lastRow = new ButtonBuilder()
			.setCustomId('end_study')
			.setLabel('作業報告をする')
			.setStyle(ButtonStyle.Primary);

		const rowComponents = new ActionRowBuilder().addComponents(lastRow);

		await textChannel.send({
			content: `${oldState.member.user.username}さんが学習を終了しました。${oldState.member.user.username}さん、作業報告を完了させてください`,
			components: [rowComponents],
		});
	}
});

client.on('interactionCreate', async (interaction) => {
	if (!interaction.isButton()) return;

	if (interaction.customId === 'start_study') {
		const modal = new ModalBuilder()
			.setCustomId('studyModal')
			.setTitle('学習目標設定');

		const goalInput = new TextInputBuilder()
			.setCustomId('goal')
			.setLabel('今の目標')
			.setStyle(TextInputStyle.Short);

		const problemInput = new TextInputBuilder()
			.setCustomId('problem')
			.setLabel('困っていること、解決したいこと')
			.setStyle(TextInputStyle.Short);

		const commentInput = new TextInputBuilder()
			.setCustomId('comment')
			.setLabel('コメント')
			.setStyle(TextInputStyle.Paragraph);

		modal.addComponents(
			new ActionRowBuilder().addComponents(goalInput),
			new ActionRowBuilder().addComponents(problemInput),
			new ActionRowBuilder().addComponents(commentInput)
		);

		await interaction.showModal(modal);
	} else if (interaction.customId === 'end_study') {
		const modal = new ModalBuilder()
			.setCustomId('studyEndModal')
			.setTitle('学習記録報告');

		const todoInput = new TextInputBuilder()
			.setCustomId('todo')
			.setLabel('やったこと')
			.setStyle(TextInputStyle.Short);

		const progressInput = new TextInputBuilder()
			.setCustomId('progress')
			.setLabel('分かったこと、わからなかったこと')
			.setStyle(TextInputStyle.Short);

		const nextDoInput = new TextInputBuilder()
			.setCustomId('nextDo')
			.setLabel('次にやること')
			.setStyle(TextInputStyle.Short);

		modal.addComponents(
			new ActionRowBuilder().addComponents(todoInput),
			new ActionRowBuilder().addComponents(progressInput),
			new ActionRowBuilder().addComponents(nextDoInput)
		);

		await interaction.showModal(modal);
	}
});

client.on(Events.InteractionCreate, async (interaction) => {
	if (!interaction.isModalSubmit()) return;
	if (interaction.customId === 'studyModal') {
		const goal = interaction.fields.getTextInputValue('goal');
		const problem = interaction.fields.getTextInputValue('problem');
		const comment = interaction.fields.getTextInputValue('comment');

		const textChannel = await client.channels.fetch(textChannelId);
		await textChannel.send(
			`目標: ${goal}\n困っていること: ${problem}\nコメント: ${comment}`
		);

		await interaction.reply({
			content: `${interaction.user.username}さん、入力ありがとうございます。学習を頑張ってください！`,
			ephemeral: true,
		});
	} else if (interaction.customId === 'studyEndModal') {
		const todo = interaction.fields.getTextInputValue('todo');
		const progress = interaction.fields.getTextInputValue('progress');
		const nextDo = interaction.fields.getTextInputValue('nextDo');

		const textChannel = await client.channels.fetch(textChannelId);
		const sec = Math.floor(duration / 1000) % 60;
		const min = Math.floor(duration / 1000 / 60) % 60;
		const hours = Math.floor(duration / 1000 / 60 / 60) % 24;
		await textChannel.send(
			`${interaction.user.username}さんが学習を終了しました。\n学習時間: ${hours}時間${min}分${sec}秒\n やったこと: ${todo}\n分かったこと: ${progress}\n次にやること: ${nextDo}`
		);
		await interaction.reply({
			content: `${interaction.user.username}さん、入力ありがとうございます。学習お疲れさまでした！次も頑張ってください`,
			ephemeral: true,
		});
	}
});

// このコードは必ず最後に追加して、Discordにログインします
client.login(
	'ここにボットのアクセストークンを入れます'
);

```

## 苦労したこと

discordのバージョンが4以降の為、調べていた記事の書き方ではなかなかうまくいかず、日本語の公式ドキュメントでは、うまくいかず、英語の公式ドキュメントを見てようやく、エラー解決に至りました。

[公式ドキュメント](https://discordjs.guide/)

## まとめ

だいぶ、はしょりましたが、きっと、調べればいろいろ出てくると思います。（完）

## 追記 学習ボタンを押したらDisabledにする(2024/2/27)

そういえばボタンを押した後もクリックできるようにしたくなかったと気が付きました。

追加の実装です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3304752/91009578-36b2-da75-8ea4-5d1a090cc3c9.png)


```index.js
		const button = new ButtonBuilder()
			.setCustomId('start_study')
			.setLabel('学習を開始する')
			.setStyle(ButtonStyle.Primary)
			.setDisabled(true);
		const row = new ActionRowBuilder().addComponents(button);
		await interaction.message.edit({ components: [row] });
  ```
  を追加するだけでいいです。

例
```index.js

//　割愛

client.on(Events.InteractionCreate, async (interaction) => {
	if (!interaction.isModalSubmit()) return;
	if (interaction.customId === 'studyModal') {
		const goal = interaction.fields.getTextInputValue('goal');
		const problem = interaction.fields.getTextInputValue('problem');
		const comment = interaction.fields.getTextInputValue('comment');

		const textChannel = await client.channels.fetch(textChannelId);
		await textChannel.send(
			`目標: ${goal}\n困っていること: ${problem}\nコメント: ${comment}`
		);

		await interaction.reply({
			content: `${interaction.user.username}さん、入力ありがとうございます。学習を頑張ってください！`,
			ephemeral: true,
		});
		const button = new ButtonBuilder()
			.setCustomId('start_study')
			.setLabel('学習を開始する')
			.setStyle(ButtonStyle.Primary)
			.setDisabled(true);
		const row = new ActionRowBuilder().addComponents(button);
		await interaction.message.edit({ components: [row] });
	} else if (interaction.customId === 'studyEndModal') {
		const todo = interaction.fields.getTextInputValue('todo');
		const progress = interaction.fields.getTextInputValue('progress');
		const nextDo = interaction.fields.getTextInputValue('nextDo');

		const textChannel = await client.channels.fetch(textChannelId);
		const sec = Math.floor(duration / 1000) % 60;
		const min = Math.floor(duration / 1000 / 60) % 60;
		const hours = Math.floor(duration / 1000 / 60 / 60) % 24;
		await textChannel.send(
			`${interaction.user.username}さんが学習を終了しました。\n学習時間: ${hours}時間${min}分${sec}秒\n やったこと: ${todo}\n分かったこと: ${progress}\n次にやること: ${nextDo}`
		);
		await interaction.reply({
			content: `${interaction.user.username}さん、入力ありがとうございます。学習お疲れさまでした！次も頑張ってください`,
			ephemeral: true,
		});
		const button = new ButtonBuilder()
			.setCustomId('end_study')
			.setLabel('作業報告をする')
			.setStyle(ButtonStyle.Primary)
			.setDisabled(true);
		const lastRow = new ActionRowBuilder().addComponents(button);
		await interaction.message.edit({ components: [lastRow] });
	}
});


```