0
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?

Discordの通話履歴を記録するBotを作る

0
Last updated at Posted at 2026-08-10

1. はじめに

Discordで通話をしていると、たまに一瞬だけ入ってすぐ抜ける人とか居ますよね。
あとはどれだけ通話しているのかな~とか気になったり...
そういったことを解決していきたいと思います。

こんな人向け!

  • DiscordのBotが作れる
  • プログラミング初学者

環境

名前 ver
Node.js 22.18.0
Discord.js 14.15.3

2. UNIX時間(POSIX時間)について

「1970年1月1日0時0分0秒からの経過秒」です。

ほぼすべてのシステム上で時間はこの数値で扱われていて、

console.log(new Date().getTime()); // → 1777884799947 (記事を書いている時刻)

などで取得できます。(.getTime()の場合は1/1000秒単位)

Discordの埋め込みメッセージ(Embed)についているタイムスタンプや「@ time」も、中身はこの数値です。
image.png

image.png
この<t:1777885099:R>がUNIX時間ですね。

つまり!!

UNIX時間の概念がわかると、2つの時刻の差を測ることが容易にできます。

[通話を退出したUNIX時間] - [通話に参加したUNIX時間] = [通話に入っていた秒数]

これを組み込むだけです。

3. コード

以下のコードで動きます。

勉強したい人用のコメントアウト付きはここ
const dotenv = require('dotenv').config();
const fs = require('fs'); // ファイルの読み書きができるパッケージ
const { Client, GatewayIntentBits } = require('discord.js'); // Discord.JS
const client = new Client(
    {
        intents: [
            GatewayIntentBits.Guilds,
            GatewayIntentBits.GuildMessages,
            GatewayIntentBits.GuildVoiceStates,
        ]
    }
);
// 設定
const databaseFile = 'voicechat-join-log.json' // いつユーザーが参加したかを記録する
const targetServerID = '0000000000000000000' // 監視するサーバーのIDを設定する
const targetLogChannelID = '0000000000000000000' // 通知するチャンネルのIDを設定する

/**
 * voiceStateUpdate はボイスチャンネルに更新があった時に反応する。
 * ユーザーの参加や退出、ミュート・スピーカーミュート・画面共有・カメラの切り替え、などなど...
 * oldStateに更新前の情報が入る
 * newStateに更新後の情報が入る
 */
client.on('voiceStateUpdate', (oldState, newState) => {
    if (oldState.member.user.bot || newState.member.user.bot) return; // Botには反応しないようにする
    if (oldState.guild.id !== targetServerID && newState.guild.id !== targetServerID) return; // 更新前・後の療法が他のサーバーの場合は反応しないようにする
    if (oldState.channelId == newState.channelId) return; // 参加・退出にのみ反応するようにする

    // 記録するファイルを読み込む
    if (!fs.existsSync(databaseFile)) fs.writeFileSync(databaseFile, '{}');
    const bufferDB = fs.readFileSync(databaseFile);
    const JsonDB = bufferDB.toString();
    let database = JSON.parse(JsonDB);

    // 更新前がnullの場合、確定で参加なので参加処理をする
    if (oldState.channelId == null && newState.guild.id == targetServerID) {
        // 先に参加通知を送る
        newState.guild.channels.cache.get(targetLogChannelID).send(newState.member.displayName + "" + newState.channel.name + "に参加しました。");
        // データベースにUNIX時間(ms)を保存
        database[newState.member.user.username] = new Date().getTime();
        const stringData = JSON.stringify(database, null, '\t');
        fs.writeFileSync(databaseFile, stringData);

    // 更新後がnullの場合、確定で退出なので退出処理をする
    } else if (newState.channelId == null && oldState.guild.id == targetServerID) {
        // 現在のUNIX時間からデータベース読み込んだUNIX時間を引く
        const VoiceChatTime = new Date().getTime() - database[oldState.member.user.username];
        // 計算後の値が通話に入っていた時間なのでそのまま通知する
        oldState.guild.channels.cache.get(targetLogChannelID).send(oldState.member.displayName + "" + oldState.channel.name + "から退出しました。 通話時間: " + VoiceChatTime + "ミリ秒")

    // 更新前・後が同じ監視しているサーバーの場合、確定で通話を横移動しているので移動処理をする
    } else if (newState.guild.id == targetServerID && oldState.guild.id == targetServerID) {
        // ここは退出処理と同じ
        const VoiceChatTime = new Date().getTime() - database[oldState.member.user.username];
        oldState.guild.channels.cache.get(targetLogChannelID).send(oldState.member.displayName + "" + oldState.channel.name + "から" + newState.channel.name + "に移動しました。 通話時間: " + VoiceChatTime + "ミリ秒")
        // ここは参加処理と同じ
        database[newState.member.user.username] = new Date().getTime();
        const stringData = JSON.stringify(database, null, '\t');
        fs.writeFileSync(databaseFile, stringData);
    
    // それ以外は更新前・後のどちらかが監視対象外のサーバーなので横移動としてではなく、参加・退出としての処理をする。
    } else {
        // 他のサーバーから監視対象のサーバーの場合、参加処理
        if (newState.guild.id == targetServerID) {
            newState.guild.channels.cache.get(targetLogChannelID).send(newState.member.displayName + "" + newState.channel.name + "に参加しました。");
            database[newState.member.user.username] = new Date().getTime();
            const stringData = JSON.stringify(database, null, '\t');
            fs.writeFileSync(databaseFile, stringData);

        // 監視対象から他のサーバーの場合、退出処理
        } else if (oldState.guild.id == targetServerID) {
            const VoiceChatTime = new Date().getTime() - database[oldState.member.user.username];
            oldState.guild.channels.cache.get(targetLogChannelID).send(oldState.member.displayName + "" + oldState.channel.name + "から退出しました。 通話時間: " + VoiceChatTime + "ミリ秒")
        }
    }
})


// Botログイン
client.login(dotenv.parsed.TOKEN)
コード
const dotenv = require('dotenv').config();
const fs = require('fs');
const { Client, GatewayIntentBits } = require('discord.js');
const client = new Client(
    {
        intents: [
            GatewayIntentBits.Guilds,
            GatewayIntentBits.GuildMessages,
            GatewayIntentBits.GuildVoiceStates,
        ]
    }
);

// 設定
const databaseFile = 'voicechat-join-log.json' // データベースファイル名
const targetServerID = '0000000000000000000' // 対象のサーバーID
const targetLogChannelID = '0000000000000000000' // 通知チャンネルID


client.on('voiceStateUpdate', (oldState, newState) => {
    if (oldState.member.user.bot || newState.member.user.bot) return;
    if (oldState.guild.id !== targetServerID && newState.guild.id !== targetServerID) return;
    if (oldState.channelId == newState.channelId) return;

    if (!fs.existsSync(databaseFile)) fs.writeFileSync(databaseFile, '{}');
    const bufferDB = fs.readFileSync(databaseFile);
    const JsonDB = bufferDB.toString();
    let database = JSON.parse(JsonDB);

    if (oldState.channelId == null && newState.guild.id == targetServerID) {
        newState.guild.channels.cache.get(targetLogChannelID).send(newState.member.displayName + "" + newState.channel.name + "に参加しました。");
        database[newState.member.user.username] = new Date().getTime();
        const stringData = JSON.stringify(database, null, '\t');
        fs.writeFileSync(databaseFile, stringData);

    } else if (newState.channelId == null && oldState.guild.id == targetServerID) {
        const VoiceChatTime = new Date().getTime() - database[oldState.member.user.username];
        oldState.guild.channels.cache.get(targetLogChannelID).send(oldState.member.displayName + "" + oldState.channel.name + "から退出しました。 通話時間: " + VoiceChatTime + "ミリ秒")

    } else if (newState.guild.id == targetServerID && oldState.guild.id == targetServerID) {
        const VoiceChatTime = new Date().getTime() - database[oldState.member.user.username];
        oldState.guild.channels.cache.get(targetLogChannelID).send(oldState.member.displayName + "" + oldState.channel.name + "から" + newState.channel.name + "に移動しました。 通話時間: " + VoiceChatTime + "ミリ秒")
        database[newState.member.user.username] = new Date().getTime();
        const stringData = JSON.stringify(database, null, '\t');
        fs.writeFileSync(databaseFile, stringData);

    } else {
        if (newState.guild.id == targetServerID) {
            newState.guild.channels.cache.get(targetLogChannelID).send(newState.member.displayName + "" + newState.channel.name + "に参加しました。");
            database[newState.member.user.username] = new Date().getTime();
            const stringData = JSON.stringify(database, null, '\t');
            fs.writeFileSync(databaseFile, stringData);

        } else if (oldState.guild.id == targetServerID) {
            const VoiceChatTime = new Date().getTime() - database[oldState.member.user.username];
            oldState.guild.channels.cache.get(targetLogChannelID).send(oldState.member.displayName + "" + oldState.channel.name + "から退出しました。 通話時間: " + VoiceChatTime + "ミリ秒")
        }
    }
})

client.login(dotenv.parsed.TOKEN)

このようになるはずです。

image.png

4. 埋め込みにして整形する

見やすく整形しましょう。
ここからは初心者向けではありません。

8~11行目のpathname はそれぞれjsonファイルの配置場所とjsonファイル名です。データベースとして使います。
12~15行目のtargetServertargetLogChannel は通話を監視するサーバーのIDとその通知ちゃんねるのIDです。

TSはこちら
import dotenv from "dotenv";
import fs, { type PathLike } from "fs";
import { ChannelType, Client, Colors, ContainerBuilder, Events, GatewayIntentBits, SectionBuilder, TextDisplayBuilder, ThumbnailBuilder, VoiceState } from "discord.js"
const client = new Client({
    intents: Object.values(GatewayIntentBits).filter((v): v is GatewayIntentBits => typeof v === "number"),
});

const DB: { path: PathLike, name: string } = {
    path: './database/',
    name: 'voicechat-join-log.json'
}
const IDs = {
    targetServer: '1053887700438102026',
    targetLogChannel: '1513604116998390002'
}

/**
 * データベースにファイルを保存する。
 * @param filename databaseフォルダ内のファイル名
 * @param data 保存する古データ
 */
function DBsave(data: any) {
    const personJSON = JSON.stringify(data, null, '\t')
    fs.writeFileSync(DB.path + DB.name, personJSON)
}

/**
 * データベースからファイルを読み込む
 * @param filename databaseフォルダ内のファイル名
 * @param init 初期の雛形
 * @returns 読み込んだJavaScriptオブジェクト
 */
function DBload(init: string): any {
    if (!fs.existsSync(DB.path + DB.name)) { fs.mkdirSync(DB.path, { recursive: true }); fs.writeFileSync(DB.path + DB.name, init); }
    const bufferData = fs.readFileSync(DB.path + DB.name)
    const dataJSON = bufferData.toString()
    const database = JSON.parse(dataJSON)
    return database
}

/**
 * EDIT: 関数名と引数名をより具体的な名称に変更
 * @param ms ミリ秒
 * @param omitMs ミリ秒表示を省略するか否か ( trueで省略 )
 * @returns 年日時間分秒 もしくはnull
 */
function formatDuration(ms: number, omitMs: boolean = false): string | null {
    if (ms <= 0) return null;
    const SECOND = 1000;
    const MINUTE = SECOND * 60;
    const HOUR = MINUTE * 60;
    const DAY = HOUR * 24;
    const YEAR = DAY * 365;
    const Y = Math.floor(ms / YEAR);
    const D = Math.floor((ms % YEAR) / DAY);
    const H = Math.floor((ms % DAY) / HOUR);
    const M = Math.floor((ms % HOUR) / MINUTE);
    const S = Math.floor((ms % MINUTE) / SECOND);
    const MS = ms % SECOND;
    const msStr = omitMs ? "" : `.${String(MS).padStart(3, '0')}`;
    if (Y >= 1) return `${Y}${D}${H}時間${M}${S}${msStr}秒`;
    if (D >= 1) return `${D}${H}時間${M}${S}${msStr}秒`;
    if (H >= 1) return `${H}時間${M}${S}${msStr}秒`;
    if (M >= 1) return `${M}${S}${msStr}秒`;
    if (S >= 1) return `${S}${msStr}秒`;

    return omitMs ? null : `0${msStr}秒`;
}

/**
 * 埋め込み作成
 * @param VoiceState 表示名などを取得するVoiceState型
 * @param username ユーザーID
 * @param leave 退出した場合時間(ms)が入る
 * @param AfterState チャンネルを移動した場合VoiceState型が入る
 * @returns 
 */
function Embed(VoiceState: VoiceState, username: string, leave?: number, AfterState?: VoiceState): ContainerBuilder {
    if (leave == undefined) {
        return new ContainerBuilder()
            .setAccentColor(Colors.Green)
            .addSectionComponents([
                new SectionBuilder()
                    .addTextDisplayComponents(
                        new TextDisplayBuilder({
                            content: `${VoiceState.member?.displayName}(${username}) が <#${VoiceState.channel?.id}> に参加しました。`
                        })
                    )
                    .setThumbnailAccessory(
                        new ThumbnailBuilder({
                            media: { url: VoiceState.member?.displayAvatarURL({ size: 64 }) ?? 'https://cdn.discordapp.com/embed/avatars/0.png' }
                        })
                    )
            ]);
    } else if (AfterState == undefined) {
        return new ContainerBuilder()
            .setAccentColor(Colors.Red)
            .addSectionComponents([
                new SectionBuilder()
                    .addTextDisplayComponents(
                        new TextDisplayBuilder({
                            content: `${VoiceState.member?.displayName}(${username}) が <#${VoiceState.channel?.id}> から退出しました。\n通話時間: ${formatDuration(leave, true)}`
                        })
                    )
                    .setThumbnailAccessory(
                        new ThumbnailBuilder({
                            media: { url: VoiceState.member?.displayAvatarURL({ size: 64 }) ?? 'https://cdn.discordapp.com/embed/avatars/0.png' }
                        })
                    )
            ])
    } else {
        return new ContainerBuilder()
            .setAccentColor(Colors.Blue)
            .addSectionComponents([
                new SectionBuilder()
                    .addTextDisplayComponents(
                        new TextDisplayBuilder({
                            content: `${VoiceState.member?.displayName}(${username}) が <#${VoiceState.channel?.id}> から <#${AfterState.channel?.id}> に移動しました。\n通話時間: ${formatDuration(leave, true)}`
                        })
                    )
                    .setThumbnailAccessory(
                        new ThumbnailBuilder({
                            media: { url: VoiceState.member?.displayAvatarURL({ size: 64 }) ?? 'https://cdn.discordapp.com/embed/avatars/0.png' }
                        })
                    )
            ])
    }
}

client.on(Events.VoiceStateUpdate, async (oldState, newState) => {
    if (oldState.member?.user.bot || newState.member?.user.bot) return //ボットフィルタ
    if (oldState.guild.id !== IDs.targetServer && newState.guild.id !== IDs.targetServer) return; //両方かさばにかんけいなければ消す
    if (oldState.channelId == newState.channelId) return; //VC移動していなければ消す
    var database: Record<string, number> = DBload('{}');
    const username = oldState.member?.user.username ?? newState.member?.user.username; if (username == undefined) return; // ユーザーネーム取得
    if (oldState.channelId == null && newState.guild.id == IDs.targetServer) { // 新規参加
        const components = Embed(newState, username);
        const ch = newState.guild.channels.cache.get(IDs.targetLogChannel); if (ch == undefined || ch.type !== ChannelType.GuildText) return;
        ch.send({ components: [components], flags: ['IsComponentsV2'] })
        // データベース処理
        database[username] = new Date().getTime()
        DBsave(database)
    } else if (newState.channelId == null && oldState.guild.id == IDs.targetServer) { // 退出
        if (database[username] == undefined) return;
        const voiceTotalTime = new Date().getTime() - database[username]
        const components = Embed(oldState, username, voiceTotalTime);
        const ch = oldState.guild.channels.cache.get(IDs.targetLogChannel); if (ch == undefined || ch.type !== ChannelType.GuildText) return;
        ch.send({ components: [components], flags: ['IsComponentsV2'] })
    } else { // 横移動
        if (newState.guild.id == IDs.targetServer && oldState.guild.id == IDs.targetServer) {
            if (database[username] == undefined) return;
            const voiceTotalTime = new Date().getTime() - database[username]
            const components = Embed(oldState, username, voiceTotalTime, newState);
            const ch = oldState.guild.channels.cache.get(IDs.targetLogChannel); if (ch == undefined || ch.type !== ChannelType.GuildText) return;
            ch.send({ components: [components], flags: ['IsComponentsV2'] })
            // データベース処理
            database[username] = new Date().getTime()
            DBsave(database)
        } else { // かさばと他のサーバーのため、独自処理
            if (newState.guild.id == IDs.targetServer) { // 新規参加
                const components = Embed(newState, username)
                const ch = newState.guild.channels.cache.get(IDs.targetLogChannel); if (ch == undefined || ch.type !== ChannelType.GuildText) return;
                ch.send({ components: [components], flags: ['IsComponentsV2'] })
                // データベース処理
                database[username] = new Date().getTime()
                DBsave(database)
            } else if (oldState.guild.id == IDs.targetServer) { // 退出
                if (database[username] == undefined) return;
                const voiceTotalTime = new Date().getTime() - database[username]
                const components = Embed(oldState, username, voiceTotalTime);
                const ch = oldState.guild.channels.cache.get(IDs.targetLogChannel); if (ch == undefined || ch.type !== ChannelType.GuildText) return;
                ch.send({ components: [components], flags: ['IsComponentsV2'] })
            }
        }
    }
});

client.login(dotenv.config().parsed?.TOKEN);
JS
import dotenv from "dotenv";
import fs, { } from "fs";
import { ChannelType, Client, Colors, ContainerBuilder, Events, GatewayIntentBits, SectionBuilder, TextDisplayBuilder, ThumbnailBuilder, VoiceState } from "discord.js";
const client = new Client({
    intents: Object.values(GatewayIntentBits).filter((v) => typeof v === "number"),
});

const DB = {
    path: './database/',
    name: 'voicechat-join-log.json'
};
const IDs = {
    targetServer: '1053887700438102026',
    targetLogChannel: '1513604116998390002'
};

function DBsave(data) {
    const personJSON = JSON.stringify(data, null, '\t');
    fs.writeFileSync(DB.path + DB.name, personJSON);
}
function DBload(init) {
    if (!fs.existsSync(DB.path + DB.name)) {
        fs.mkdirSync(DB.path, { recursive: true });
        fs.writeFileSync(DB.path + DB.name, init);
    }
    const bufferData = fs.readFileSync(DB.path + DB.name);
    const dataJSON = bufferData.toString();
    const database = JSON.parse(dataJSON);
    return database;
}
function formatDuration(ms, omitMs = false) {
    if (ms <= 0)
        return null;
    const SECOND = 1000;
    const MINUTE = SECOND * 60;
    const HOUR = MINUTE * 60;
    const DAY = HOUR * 24;
    const YEAR = DAY * 365;
    const Y = Math.floor(ms / YEAR);
    const D = Math.floor((ms % YEAR) / DAY);
    const H = Math.floor((ms % DAY) / HOUR);
    const M = Math.floor((ms % HOUR) / MINUTE);
    const S = Math.floor((ms % MINUTE) / SECOND);
    const MS = ms % SECOND;
    const msStr = omitMs ? "" : `.${String(MS).padStart(3, '0')}`;
    if (Y >= 1)
        return `${Y}${D}${H}時間${M}${S}${msStr}秒`;
    if (D >= 1)
        return `${D}${H}時間${M}${S}${msStr}秒`;
    if (H >= 1)
        return `${H}時間${M}${S}${msStr}秒`;
    if (M >= 1)
        return `${M}${S}${msStr}秒`;
    if (S >= 1)
        return `${S}${msStr}秒`;
    return omitMs ? null : `0${msStr}秒`;
}
function Embed(VoiceState, username, leave, AfterState) {
    if (leave == undefined) {
        return new ContainerBuilder()
            .setAccentColor(Colors.Green)
            .addSectionComponents([
                new SectionBuilder()
                    .addTextDisplayComponents(new TextDisplayBuilder({
                        content: `${VoiceState.member?.displayName}(${username}) が <#${VoiceState.channel?.id}> に参加しました。`
                    }))
                    .setThumbnailAccessory(new ThumbnailBuilder({
                        media: { url: VoiceState.member?.displayAvatarURL({ size: 64 }) ?? 'https://cdn.discordapp.com/embed/avatars/0.png' }
                    }))
            ]);
    }
    else if (AfterState == undefined) {
        return new ContainerBuilder()
            .setAccentColor(Colors.Red)
            .addSectionComponents([
                new SectionBuilder()
                    .addTextDisplayComponents(new TextDisplayBuilder({
                        content: `${VoiceState.member?.displayName}(${username}) が <#${VoiceState.channel?.id}> から退出しました。\n通話時間: ${formatDuration(leave, true)}`
                    }))
                    .setThumbnailAccessory(new ThumbnailBuilder({
                        media: { url: VoiceState.member?.displayAvatarURL({ size: 64 }) ?? 'https://cdn.discordapp.com/embed/avatars/0.png' }
                    }))
            ]);
    }
    else {
        return new ContainerBuilder()
            .setAccentColor(Colors.Blue)
            .addSectionComponents([
                new SectionBuilder()
                    .addTextDisplayComponents(new TextDisplayBuilder({
                        content: `${VoiceState.member?.displayName}(${username}) が <#${VoiceState.channel?.id}> から <#${AfterState.channel?.id}> に移動しました。\n通話時間: ${formatDuration(leave, true)}`
                    }))
                    .setThumbnailAccessory(new ThumbnailBuilder({
                        media: { url: VoiceState.member?.displayAvatarURL({ size: 64 }) ?? 'https://cdn.discordapp.com/embed/avatars/0.png' }
                    }))
            ]);
    }
}
client.on(Events.VoiceStateUpdate, async (oldState, newState) => {
    if (oldState.member?.user.bot || newState.member?.user.bot)
        return;
    if (oldState.guild.id !== IDs.targetServer && newState.guild.id !== IDs.targetServer)
        return;
    if (oldState.channelId == newState.channelId)
        return;
    var database = DBload('{}');
    const username = oldState.member?.user.username ?? newState.member?.user.username;
    if (username == undefined)
        return;
    if (oldState.channelId == null && newState.guild.id == IDs.targetServer) {
        const components = Embed(newState, username);
        const ch = newState.guild.channels.cache.get(IDs.targetLogChannel);
        if (ch == undefined || ch.type !== ChannelType.GuildText)
            return;
        ch.send({ components: [components], flags: ['IsComponentsV2'] });
        database[username] = new Date().getTime();
        DBsave(database);
    }
    else if (newState.channelId == null && oldState.guild.id == IDs.targetServer) {
        if (database[username] == undefined)
            return;
        const voiceTotalTime = new Date().getTime() - database[username];
        const components = Embed(oldState, username, voiceTotalTime);
        const ch = oldState.guild.channels.cache.get(IDs.targetLogChannel);
        if (ch == undefined || ch.type !== ChannelType.GuildText)
            return;
        ch.send({ components: [components], flags: ['IsComponentsV2'] });
    }
    else {
        if (newState.guild.id == IDs.targetServer && oldState.guild.id == IDs.targetServer) {
            if (database[username] == undefined)
                return;
            const voiceTotalTime = new Date().getTime() - database[username];
            const components = Embed(oldState, username, voiceTotalTime, newState);
            const ch = oldState.guild.channels.cache.get(IDs.targetLogChannel);
            if (ch == undefined || ch.type !== ChannelType.GuildText)
                return;
            ch.send({ components: [components], flags: ['IsComponentsV2'] });
            database[username] = new Date().getTime();
            DBsave(database);
        }
        else {
            if (newState.guild.id == IDs.targetServer) {
                const components = Embed(newState, username);
                const ch = newState.guild.channels.cache.get(IDs.targetLogChannel);
                if (ch == undefined || ch.type !== ChannelType.GuildText)
                    return;
                ch.send({ components: [components], flags: ['IsComponentsV2'] });
                database[username] = new Date().getTime();
                DBsave(database);
            }
            else if (oldState.guild.id == IDs.targetServer) {
                if (database[username] == undefined)
                    return;
                const voiceTotalTime = new Date().getTime() - database[username];
                const components = Embed(oldState, username, voiceTotalTime);
                const ch = oldState.guild.channels.cache.get(IDs.targetLogChannel);
                if (ch == undefined || ch.type !== ChannelType.GuildText)
                    return;
                ch.send({ components: [components], flags: ['IsComponentsV2'] });
            }
        }
    }
});

client.login(dotenv.config().parsed?.TOKEN);

ちゃんと動けばこのようになります。

image.png

(ComponentsV2を使っているのは私が使ってみたかったからです。)

5. おわり

昔書いたコードを今の私ならどうするかと思い、どうせなら記事にしてみました。
プログラミングの勉強としてちょうどよかった記憶があるので、ぜひ糧にしてください。

0
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
0
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?