1日1回 名言を投稿してくれる discord.bot を作る

今回は、1日1回 名言を投稿してくれるDiscord.botを作ってみました。

名言を返してくれる公開APIとかがどこかにあるかと思って始めたんですが、見つからなかったので、「ChatGPT先生」に頼って、名言を集めてきました。

## 構成
Google Spreadsheet に名言をリスト化し、GASで、ランダムに名言を返すAPIを作りました。
Discordのbotから、その名言データを読み込んで、指定の通知時間に、チャンネルに投稿するようにしました。

botはコマンドで操作できるようにしました。

![スクリーンショット 2024-12-30 7.57.55.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/186659/5c67c375-c5f0-534e-caf0-eceb2563ec9f.png)


## 文言リストAPI部分

### ChatGPT
「名言を、内容,発言者で、500個、CSV形式で出力してー」
ってお願いすると、最初は30個くらい、そのあと100個にしてって言ったら、100個くらいずつ名言を出してくれました。

Google Spreadsheet にコピペです。
こんな感じ↓↓↓です。

![スクリーンショット 2024-12-29 21.24.25.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/186659/d447e0c1-ca84-c2e0-49dc-815ab5c50ce3.png)

シート名は「``meigen``」にしました。

### API作成
APIといっても、GASで言う「ウェブアプリ」です。
下記のコードを書きました。

```
function doGet(e){
  var rv = "";
  var rand;

  // シート取得→データ取得
  const sheet = SpreadsheetApp.getActive().getSheetByName('meigen');
  const lastRow = sheet.getLastRow();
  const lastColumn = sheet.getLastColumn();

  data = sheet.getDataRange().getValues();
  rand = Math.random();
  rand = Math.floor(rand*lastRow);
  rv = data[rand][0] + "（" + data[rand][1] +"）";
  return ContentService.createTextOutput(rv);
  // 戻り値：乱数で選んだ行のデータ。「名言（発言者）」
}
```

### デプロイ
詳細は、過去記事や、その他記事にたくさんありますので、割愛しますが、「デプロイ」して、「URL」をメモります。

``
https://script.google.com/macros/s/*****hogehoge****/exec
``

これ叩くだけで、名言が出ますので、それで充分とも言いますが、続けます(^^)

## botのコード
完成のコードです。冗長な部分もたくさんありますが、疲れちゃったので、これでOKってことにしました。
今回は、コード内に説明をたくさん書いたので、みてください。

```
const { Client, GatewayIntentBits } = require('discord.js');
const schedule = require('node-schedule');
const axios = require("axios");
const fs = require('fs');
const csv = require('csv-parser');
const { writeToStream } = require('fast-csv');

const url = "https://script.google.com/macros/s/*****hogehoge*****/exec";

let SetTime = [];		// 通知時間の管理配列
let SetChannel = [];	// チャンネル名管理配列
let NumOfChannel= 0;	// 登録チャンネル数

const jobs = [];

let rv = "犬も歩けば棒にあたる（初期値）";	// 名言変数

// ここに自分のBotのトークン
const token = '****あなたのトークン****';

// Discordのクライアントを作成
const client = new Client({
  intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMessages, GatewayIntentBits.MessageContent]
});

/*
 * Botが起動した時の処理
 * ・CSVファイルにある「チャンネルID」「通知時間設定」を読み、その時間で名言を通知するジョブを作成
 * ・名言の初期読み込みと、名言を定期的に更新するジョブを作成
 */
client.once('ready', async () => {
		console.log(`[${new Date().toLocaleString()}]`,'Bot is ready!');	// 起動メッセージ
		// 1回読んで名言の初期値をセット
		try {
			const response = await axios.get(url);
			rv = response.data;
			console.log(`[${new Date().toLocaleString()}]1st-Meigen:${rv}`);
		} catch (error) {
			console.error(`[${new Date().toLocaleString()}]GAS呼び出しエラー`, error);
		}
		// CSVファイルを読み込み、チャンネル毎にジョブを作成。
		fs.createReadStream('settime.csv')
		.pipe(csv())
		.on('data', (row) => {
				NumOfChannel++;
				SetChannel[NumOfChannel] = row.channel;
				SetTime[NumOfChannel] = row.time;
				const parts = row.time.split(":");
				const hhmmcron = parts[1] + " " + parts[0] + " * * *";	// cron形式に整形
				const jobname = SetChannel[NumOfChannel];
				addOrUpdateJob(jobname, hhmmcron , async () => {
						const channel = client.channels.cache.get(SetChannel[NumOfChannel]); 
						channel.send(rv);
						console.log(`[${new Date().toLocaleString()}_${SetChannel[NumOfChannel]}]Delivary: `,rv);
				});
		})
		.on('end', () => {
				listJobs();
		});
		// 名言を定期的に更新するようにジョブ化
		addOrUpdateJob('Job(MeigenUpdate)', '0 12 * * *' , async () => {	// 毎日12:00に更新
				try {
					const response = await axios.get(url);
					rv = response.data;
					console.log(`[${new Date().toLocaleString()}]Meigen: ${rv}`);
				} catch (error) {
					console.error(`[${new Date().toLocaleString()}]GAS呼び出しエラー`, error);
				}
			console.log(`[${new Date().toLocaleString()}]Meigen Update finished.`);
		});
});

/*
 * Discord コマンド対応
 * !meigentime HH:MM	→指定時間に通知時間を変更
 * !meigentime	→現在の通知時間を表示
 * !meigen	→名言を通知
 */
client.on('messageCreate', (message) => {
		const this_channel = message.channel.id;
		let this_settime = "12:00";	// 初期値
		let channel_index = 0;
		// チャンネルが登録されているかチェック
		for (let i=1;i<=NumOfChannel;i++) {
			if (SetChannel[i] === this_channel) {
				this_settime = SetTime[i];
				channel_index = i;
			}
		}
		// ない場合はチャンネル新規登録＆CSVファイルに初期値で書き込み
		if (channel_index === 0) {
			SetChannel.push(this_channel);	// channel列を格納
			SetTime.push(this_settime);  // time列を格納
			NumOfChannel++;
			channel_index = NumOfChannel;
			console.log("New! ",this_channel,this_time);
			csvoutput();	// CSV出力
		}
		
		if (message.author.bot) return;	// 関係ないコマンドを無視
		
		// 通知時間を設定する場合
		if (message.content.startsWith('!meigentime')) {
			var args = message.content.trim().split(/\s+/);	// パラメータを取り出す
			if (args.length === 1) {	// パラメータなしのときは現状の通知時間を表示
				message.reply(`定期配信時間は、${this_settime}です。`);
				console.log(`[${new Date().toLocaleString()}_${SetChannel[channel_index]}]_設定時間: ${this_settime}`);
			}
			else if (args.length === 2) {	//パラメータが2つのときは通知時間を更新
				const parts = args[1].split(":");
				// 通知時間 数字チェック
				const hh = parseInt(parts[0], 10);
				const mm = parseInt(parts[1], 10);
				console.log(hh,mm);
				f =  ( isNaN(hh) || hh < 0 || hh > 23 || isNaN(mm) || mm < 0 || mm > 59 );
				console.log(`f= ${f}`);	// f=falseのときチェックOK
				// 通知時間を更新
				if (!f) {
					SetTime[channel_index] = args[1];
					const hhmmcron = parts[1] + " " + parts[0] + " * * *";
					const jobname = SetChannel[channel_index];
					addOrUpdateJob(jobname , hhmmcron , async () => {
							const channel = client.channels.cache.get(SetChannel[channel_index]); 
							channel.send(rv);
							console.log(`[${new Date().toLocaleString()}_${SetChannel[channel_index]}_${SetTime[channel_index]}]`,rv,);
					});
					console.log(`[${new Date().toLocaleString()}_${SetChannel[channel_index]}]SetTime:_${SetTime[channel_index]}`);
					message.reply(`通知時間を ${SetTime[channel_index]} に設定しました`);
					csvoutput();	// CSV出力
					listJobs();	// ジョブの状態
				}
				else {
					message.reply("Error. Usage: !meigentime HH:MM");
					console.log(`[${new Date().toLocaleString()}_${SetChannel[channel_index]}]`,'時間範囲設定エラー');
				}
			}
			else {
				message.reply("Error. Usage: !meigentime HH:MM");
				console.log(`[${new Date().toLocaleString()}_${SetChannel[channel_index]}]`,'その他エラー');
			}
		}
		// 時間に関係なく名言を応答
		else if (message.content.startsWith('!meigen')) {
			message.reply(rv);
			console.log(`[${new Date().toLocaleString()}_${SetChannel[channel_index]}]_${rv}_${SetTime[channel_index]}`);
			listJobs();
		}
});
	
// Botをログイン
client.login(token);

/**
 * CSV書き出し関数（settime.csv）
 */
function csvoutput() {
	const header = ['channel', 'time'];
	// 配列を行ごとにまとめる
	const data = SetChannel.map((channel, index) => [
		channel,
		SetTime[index]
	]);
	// 書き込みストリームを作成
	const stream = fs.createWriteStream('settime.csv');
	// ストリームにデータを書き込む
	writeToStream(stream, [header, ...data], { headers: false })
	.on('error', (err) => {
			console.error(`[${new Date().toLocaleString()}]Error writing to CSV file:`, err);
	})
	.on('finish', () => {
			console.log(`[${new Date().toLocaleString()}]CSV file saved successfully.`);
	});
}

/**
 * ジョブを追加または更新する関数
 * @param {string} jobName - ジョブの名前
 * @param {string} schedulePattern - CRON スケジュールパターン
 * @param {function} callback - 実行する関数
 */
function addOrUpdateJob(jobName, schedulePattern, callback) {
	// ジョブ削除してから
	try {
		jobs[jobName].cancel();
		console.log(`[${new Date().toLocaleString()}]Job: "${jobName}" has been canceled.`);
	} catch (error) {
		console.log(`[${new Date().toLocaleString()}]Job: "${jobName}" does not exist.`);
	}
	// 新しいジョブを作成して登録
	jobs[jobName] = schedule.scheduleJob(schedulePattern, callback);
	console.log(`[${new Date().toLocaleString()}]Job: "${jobName}" has been added/updated.`);
}

/**
 * 全ジョブを表示する関数
 */
function listJobs() {
	console.log(`[${new Date().toLocaleString()}]Scheduled Jobs:`);
	for (const [name, job] of Object.entries(jobs)) {
		console.log(`[${new Date().toLocaleString()}]-- ${name}: Next Invocation - ${job.nextInvocation().toString()}`);
	}
}
```

### npm
#### discord.js
これは必須ですね(^^)
```
$ npm install discord.js
```

#### GAS-API呼び出し
``axios`` を使いました。
```
$ npm install axios
```

#### 定期通知
``node-schedule`` を使いました。
```
$ npm install node-schedule
```

登録と更新用に、``addOrUpdateJob``関数を作りました。
jobnameが配列のインデックスになっていて、新しいスケジュールパタン（cron型）で設定します。
``jobs[jobName] = schedule.scheduleJob(schedulePattern, callback);``
``node-schedule``には「更新」って言うのがないみたいなので、上記の前に
``jobs[jobName].cancel();``で、先にジョブを削除しています。``try``で囲っているので初めてのチャンネルのときは削除は失敗してログでわかるようにしました。

尚、
``listjobs``関数で、今のスケジュール化されたJobをconsoleに出すようにしました（デバック用）。

#### 通知時間の保持
CSVファイルを作って、そこに設定値を保存するようにしました。
GASでスプレッドシートに書き込もうかと思ったんですが、面倒だったのでファイルで・・
```
$ npm install csv-parser
$ npm install fast-csv
```

### 設定用CSVファイル
というわけで、上記保存用（設定用）のcsvファイルを、エディタで、作りました。
先ずは、自分の開発用チャンネルです。
チャンネルIDは、チャンネル名を右ボタンすると出ます。
あ、開発者モードじゃないとダメだったかも。
「ユーザー設定」→「詳細設定」で、開発者モードにしてください。
```
settime.csv

channel,time
13*****************,12:00
```

設定値が変更になったときに、最新の情報で上書き更新する関数を作りました。
``csvoutput``関数です。

## discordへの登録
discordへの登録や、実際の実行環境の話しは、過去記事を参照ください。
https://qiita.com/ABK28/items/26808c2ee2c6984b59e9
https://qiita.com/ABK28/items/148dff30f69663aa29bb


## デバッグは、ChatGPT先生で！

今回は、かなりChatGPT先生に頼りました。
的確な指摘でびっくり。
いやー、便利になりました。

node.jsってなんとなく動いちゃって、変な結果になってることありますよね？（自分がボケたコードを書くからですが・・）
そういうのも、ちゃんと指摘してくれて、嬉しいですね。

## 完成
めでたしめでたし。