🥺

クソしょうもないdiscord botを作って、身内鯖で稼働させてる話 【初学者】

2024/06/07に公開

初投稿です。文章をまともに書いたことが無いので分かりづらい文章であろうことを先に謝罪しておきます。

どんなBotか

前置きはなしで早速見てもらいます。

シンプルにdiscordのテキストチャンネルに時報してくれるというものです。
他にも、スラッシュコマンドを使うことでおみくじを引くことができます。
いかにも初心者が作った感じがしますよねw

Node.jsを使用して動かしている(このBotはbunで動いている)のですが、基本がたくさん詰まってていい勉強になるBotを作れたと思うのでNode.jsやjavascriptの初学者でも同じようなものなら作れるようにと思って紹介します。

完全無料となると面倒くさいので、どこかにデプロイ等はせず、自分のPCで動かしています。(実家の電気ちゅるちゅる)

開発環境

OS Windows11
言語 Typescript
パッケージマネージャー&ランタイム bun(Node.jsでも可)
エディター VSCode

使用したライブラリ

  • discord.js v14
  • dotenv
  • eslint
  • moment-timezone
  • node-cron

bun環境じゃない場合

  • typescript
  • ts-node

以下、すべてパッケージマネージャー及びランタイムはすべてbunを使用した説明になります。npm, yarn, pnpm等を使う場合適宜読み替えをお願いします。

Botを作るぞ!

まずはBotのアカウントを作る必要があるので、https://discord.com/developers/applications にアクセスしてもろもろの設定をします。これについては他に沢山高質な記事があると思うのでそちらを参照してください。

プロジェクトフォルダの作成

まず、新しくフォルダーを作りエディターで開きます。
開いたら、ターミナルで初期化をします。

bun init -y

とりあえずこれでOKです。

必要なライブラリのインストール

使用するライブラリをすべてインストールします。

bun i discord.js dotenv eslint moment-timezone node-cron

eslintのセットアップをします。

bunx eslint --init

矢印キーで操作して次の選択肢を選択してください。
? How would you like to use ESLint?
→ To check syntax and find problems
? What type of modules does your project use?
→ JavaScript modules (import/export)
? Which framework does your project use?
→ None of these
? Does your project use TypeScript?
→ Yes
? Where does your code run?
→ aキーを押して両方選択
? Would you like to install them now?
→ Yes
? Which package manager do you want to use?
→ 自分の環境のものを選択


bun環境じゃない場合

npm i --save-dev typescript ts-node

コードを書いていこう!

まずは一旦Botが起動するかどうか確認するためにちょっとだけ書きます。
プロジェクトフォルダ直下に .env.local というファイルを作成して、developerポータルでコピーしたTOKENを入れておきます。

.env.local
DISCORD_TOKEN=********************** //ここにTOKENを入れる

次にindex.tsファイルを書きます。

index.ts
import dotenv from "dotenv";
import { Client, GatewayIntentBits } from "discord.js";

dotenv.config({ path: '.env.local' });

const client = new Client({
    intents: [
        GatewayIntentBits.Guilds,
        GatewayIntentBits.GuildMembers,
        GatewayIntentBits.MessageContent
    ]
});

client.once('ready', c => {
    console.log(`${c.user.tag}がログインしました`);
});

client.login(process.env.DISCORD_TOKEN);

これで一度、ターミナルを開いて

bun index.ts

として、コンソールに {botの名前}がログインしましたと出たか確認してください。
確認できたら、時報機能を実装していきます。

時報機能の実装

設定した時刻にメッセージを送信するために、node-cronというライブラリを用いて実装します。
node-cronの詳しい使い方はnpmのドキュメントを見てください。

さて、時報機能を実装するにあたって必要な情報は『時報する時刻』、『メッセージ内容』の2つです。
これらをjsonで管理して、node-cronでスケジュールするようにしましょう。
プロジェクトフォルダのルートディレクトリにjsonフォルダを作成し、そのなかに管理するjsonファイルを作成します。

/json/reminders.json
[
    {
        "time": "00:00",
        "content": "日付が変わりました"
    },
    {
        "time": "3:34",
        "content": "な阪関無"
    }
]

次に、これを使ってnode-cronでスケジューリングします。

index.ts
import dotenv from "dotenv";
import { Client, GatewayIntentBits, type TextChannel } from "discord.js";
import { schedule, type ScheduledTask } from "node-cron"; //インポートを追加
import { existsSync, readFileSync } from "fs"; //インポートを追加

dotenv.config({ path: '.env.local' });

const client = new Client({
    intents: [
        GatewayIntentBits.Guilds,
        GatewayIntentBits.GuildMembers,
        GatewayIntentBits.MessageContent
    ]
});

client.once('ready', c => {
    console.log(`${c.user.tag}がログインしました`);
    getReminder(); //初めに呼び出す
});

//以下時報機能
interface Reminder {
    time: string,
    content: string,
}
const jobs: ScheduledTask[] = []; //スケジュールするタスクを保存する配列
const sendChannel = "ここに時報を送信したいチャンネルのID";

//jsonから情報を取ってnode-cronでスケジューリングする関数
function getReminder() {
    //もともとあるタスクを初期化
    jobs.forEach(job => job.stop());
    jobs.length = 0;
    //jsonファイルから情報を取得
    let reminders: Reminder[] = [];
    try {
        if(existsSync("./json/reminders.json")) {
            reminders = JSON.parse(readFileSync("json/reminders.json", "utf-8"));
        }
    } catch (e) {
        console.error(e);
    }
    //node-cronでスケジューリング
    reminders.forEach(reminder => {
        const { time, content } = reminder;
        const [ hour, minute ] = time.split(":");
        const sendChannel = client.channels.cache.get(sendChannel);

        const job = schedule(`${minute} ${hour} * * *`, () => {
            (sendChannel as TextChannel).send(content);
        });
        jobs.push(job);
    });
}

client.login(process.env.DISCORD_TOKEN);

これで実装完了です!
試しに、現在の時刻に近い時刻で設定してbotを起動してみてください。設定した時刻になると、指定したチャンネルにメッセージが送信されていると思います。

スラッシュコマンドで時報を設定できるようにしよう

最後に、discordにあるスラッシュコマンドを使用して、時報の作成、編集、削除をできるように実装していきましょう。

まずはindex.tsにコマンドを受け付けるコードを追加します。

index.ts
import dotenv from "dotenv";
import { Client, GatewayIntentBits, Collection, type TextChannel, CommandInteraction } from "discord.js";
import { schedule, type ScheduledTask } from "node-cron";
import { readdirSync, existsSync, readFileSync } from "fs";

dotenv.config({ path: '.env.local' });

const client = new Client({
    intents: [
        GatewayIntentBits.Guilds,
        GatewayIntentBits.GuildMembers,
        GatewayIntentBits.MessageContent
    ]
});
const collection = new Collection(); //コレクションクラスをインスタンス化

client.once('ready', c => {
    console.log(`${c.user.tag}がログインしました`);
    getReminder(); //初めに呼び出す
});

//以下スラッシュコマンド
interface Command {
    data: {
        name: string;
    };
    execute: (interaction: CommandInteraction) => Promise<void>;
}
//コマンドファイルを読み込む
const commandFiles = readdirSync("./commands").filter(file => file.endWith("ts")); //commandsフォルダの中から.tsで終わるファイルのみを見つける
for ( const file of commandFiles ) {
    const command: Command = require(`./commands/${file}`);
    collection.set(command.data.name, command);
}
//スラッシュコマンドを受け付ける
client.on("interactionCreate", async interaction => {
    if(!interaction.isCommand()) return; //スラッシュコマンドじゃないならリターン
    const comand: any = collection.get(interaction.commandName); //ここの型がわからん
    if(!command) return; //コマンドの名前と一致しなかったらリターン

    try{
        await command.execure(interaction);
        if(interaction.commandName === "setschedule" || interaction.commandName === "edit" || intaraction.commandName === "delete"){
            await getReminder(); //設定、編集、削除をしたらスケジュールし直す
        }
    } catch(e) {
        console.error(e);
        await interaction.reply({ content: "コマンドの実行中にエラーが発生しました。", ephemeral: true });
    }
});

client.login(process.env.DISCORD_TOKEN);

次にコマンドファイルを作っていきます。
プロジェクトフォルダのルートディレクトリにcommandsフォルダを作成して、その中にdelete.ts, edit.ts, setschedule.ts, scheduleindex.tsを作成します。それぞれ削除コマンド、編集コマンド、作成コマンド、一覧を表示するコマンドです。

/commands/delete.ts
import { SlashCommandBuilder } from '@discordjs/builders';
import type { CommandInteraction } from 'discord.js';
import { writeFileSync } from 'fs';
import { tz } from 'moment-timezone';

export const data = new SlashCommandBuilder()
  .setName('delete')
  .setDescription('指定したIDの時報を削除します。')
  .addStringOption(option => option.setName('id')
    .setDescription('削除する時報のID')
    .setRequired(true));
export async function execute(interaction: CommandInteraction) {
  const id = interaction.options.data.find(opt => opt.name === "id")?.value;

  const reminders = require('../json/reminders.json');
  const reminderIndex = reminders.findIndex((reminder: { id: string }) => reminder.id === id);

  if (reminderIndex === -1) {
    return await interaction.reply(`ID:${id} の時報は見つかりませんでした。`);
  }

  reminders.splice(reminderIndex, 1);

  reminders.sort((a: { time: string; }, b: { time: string; }) => {
    const timeA = tz(a.time, 'HH:mm', 'Asia/Tokyo');
    const timeB = tz(b.time, 'HH:mm', 'Asia/Tokyo');
    return timeA.diff(timeB);
  }); // 日本時間でソートする
  reminders.forEach((reminder: { id: string; }, index: number) => {
    reminder.id = (index + 1).toString();
  }); //idの振り直し 

  writeFileSync('json/reminders.json', JSON.stringify(reminders, null, 2));

  await interaction.reply(`ID: ${id} の時報を削除しました。`);
}

/commands/edit.ts
import { SlashCommandBuilder } from '@discordjs/builders';
import type { CommandInteraction } from 'discord.js';
import { writeFileSync } from 'fs';
import { tz } from 'moment-timezone';

export const data = new SlashCommandBuilder()
  .setName('edit')
  .setDescription('指定したIDの時報時刻と内容を編集します。IDは/indexで確認できます。')
  .addStringOption(option => option.setName('id')
    .setDescription('編集する時報のID')
    .setRequired(true))
  .addStringOption(option => option.setName('time')
    .setDescription('新しく時報する時刻 22:22 のように入力してください。')
    .setRequired(true))
  .addStringOption(option => option.setName('content')
    .setDescription('新しい時報のメッセージの内容(空白の場合はデフォルトの時報となります。)')
    .setRequired(false));
export async function execute(interaction: CommandInteraction) {
  const id = interaction.options.data.find(opt => opt.name === "id")?.value;
  const time = interaction.options.data.find(opt => opt.name === "time")?.value as string;
  let content = interaction.options.data.find(opt => opt.name === "content")?.value || "";

  if (content === "") {
    content = `${time}をお知らせします。`;
  };

  const [hour, minute] = time.split(':');
  if(hour >= "24" || minute >= "60"){
    await interaction.reply({ content: '不正な時間入力です。00 ~ 23時 または 00 ~ 59分の値を入力してください。 ', ephemeral: true });
    return
  }

  const reminders = require('../json/reminders.json');
  const reminderIndex = reminders.findIndex((reminder: { id: any; }) => reminder.id === id);

  if (reminderIndex === -1) {
    return await interaction.reply(`ID: ${id} の時報は見つかりませんでした。`);
  }

  reminders[reminderIndex].time = time;
  if (content !== null) {
    reminders[reminderIndex].content = content;
  }

  reminders.sort((a: { time: string; }, b: { time: string; }) => {
    const timeA = tz(a.time, 'HH:mm', 'Asia/Tokyo');
    const timeB = tz(b.time, 'HH:mm', 'Asia/Tokyo');
    return timeA.diff(timeB);
  }); // 日本時間でソートする
  reminders.forEach((reminder: { id: string; }, index: number) => {
    reminder.id = (index + 1).toString();
  }); //idの振り直し    
  writeFileSync('json/reminders.json', JSON.stringify(reminders, null, 2));

  await interaction.reply(`ID:${id} の時報を編集しました。`);
}

/commands/setschedule.ts
import { SlashCommandBuilder } from '@discordjs/builders';
import { writeFileSync } from 'fs';
import { tz } from 'moment-timezone';
import type { CommandInteraction } from 'discord.js';

export const data = new SlashCommandBuilder()
  .setName('setschedule')
  .setDescription('時報する時刻とメッセージを設定できます。')
  .addStringOption(option => option.setName('time')
    .setDescription('時報する時刻 22:22 のように入力してください。')
    .setRequired(true))
  .addStringOption(option => option.setName('content')
    .setDescription('時報のメッセージの内容(空白の場合はデフォルトの時報となります。)')
    .setRequired(false));
export async function execute(interaction: CommandInteraction) {
  const time = interaction.options.data.find(opt => opt.name ==='time')?.value as string;
  let content = interaction.options.data.find(opt => opt.name === 'content')?.value || "";
  if (content === "") {
    content = `${time}をお知らせします。`;
  };
  const [hour, minute] = time.split(':');

  if(hour >= "24" || minute >= "60"){
    await interaction.reply({ content: '不正な時間入力です。00 ~ 23時 または 00 ~ 59分の値を入力してください。 ', ephemeral: true });
    return
  }

  // リマインダーの保存
  const reminders = require('../json/reminders.json');
  reminders.push({ time, content });
  reminders.sort((a: { time: string; }, b: { time: string; }) => {
    const timeA = tz(a.time, 'HH:mm', 'Asia/Tokyo');
    const timeB = tz(b.time, 'HH:mm', 'Asia/Tokyo');
    return timeA.diff(timeB);
  }); // 日本時間でソートする
  reminders.forEach((reminder: { id: string; }, index: number) => {
    reminder.id = (index + 1).toString();
  }); //idの振り直し
  writeFileSync('json/reminders.json', JSON.stringify(reminders, null, 2));

  await interaction.reply(`${time}に時報を設定しました。`);
}

/commands/scheduleindex.ts
import { SlashCommandBuilder } from '@discordjs/builders';
import { CommandInteraction, EmbedBuilder } from 'discord.js';
import { readFileSync } from 'fs';

export const data = new SlashCommandBuilder()
  .setName('index')
  .setDescription('現在設定されている時報時刻を表示します。');
export async function execute(interaction: CommandInteraction) {
  let reminders = [];
  try {
    const data = readFileSync("json/reminders.json", "utf-8");
    reminders = JSON.parse(data);
  } catch (error) {
    console.error(error);
  }

  let reminderList = '';

  const embed = new EmbedBuilder()
    .setTitle('現在の時報設定');

  for (const reminder of reminders) {
    const { id, time, content } = reminder;
    reminderList += `${id} ${time} ${content}\n`;
    embed.addFields({ name: `${time} ID:${id}`, value: content });
  }

  await interaction.reply({ embeds: [embed] });
}

これにて、コマンドも実装することができました。しかし、まだ使うことはできません。これらのコマンドはサーバーに登録してあげないといけないからですね。
では、登録する用のファイルを作って、登録もしましょう!

regist-commands.ts
import dotenv from "dotenv";
import { REST, Routes, type APIApplicationCommand } from 'discord.js';
import { readdirSync } from 'fs';

dotenv.config({ path: '.env.local' });

const clientId = ""; //クライアントID
const guildId = ""; //登録するサーバーID
const commands = [];
const commandFiles = readdirSync('./commands').filter(file => file.endsWith('.ts'));

for (const file of commandFiles) {
	const command = require(`./commands/${file}`);
	commands.push(command.data.toJSON());
}

const rest = new REST({ version: '10' }).setToken(process.env.DISCORD_TOKEN as string);

(async () => {
	try {
		console.log(`${commands.length} 個のアプリケーションコマンドを登録します。`);

		const data = await rest.put(
			Routes.applicationGuildCommands(clientId, guildId),
			{ body: commands },
		) as APIApplicationCommand[];

		console.log(`${data.length} 個のアプリケーションコマンドを登録しました。`);
	} catch (error) {
		console.error(error);
	}
})();

clientIdとguildIdを入力した後、ターミナルで実行します。

bun regist-commands.ts

コンソールに4個のアプリケーションコマンドを登録しました。と出たら成功です!お疲れ様でした!

実際につかってみよう

登録したサーバーのテキストチャンネルならどこでもいいので、『/』と入力してみてください。
先ほど登録したコマンドが出てくると思います。
Botを起動して、実際にやってみてください。

bun index.ts

実際に使用して、jsonがしっかり更新されているかも確認してみてください。

最後に

最初にも書きましたが、実行環境がbunのため、typescriptをネイティブ実行できます。
しかし、Node.jsを使用する場合、typescriptからjavascriptへトランスパイルする必要があるため、注意する必要があります。
また、僕はクラウドサービスにデプロイ等せず、自分のPCで実行しているため、デプロイする場合の注意事項などは分かりませんが、他の方の記事を参考にしたりして挑戦するのもいいと思います!(今現在、完全無料は面倒そうですが)

自身も初学者なので、拙い文章やコードだと思いますが、最後まで見てくれてありがとうございました。
ミスなどありましたら、ご指摘くださると幸いです。

このBotのリポジトリ↓
https://github.com/miyabitti256/JihouBot
(更に一日に一度おみくじを引くことができたり、botの権限の高さを乱用したメッセージ削除コマンドが実装されています。)

Discussion