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」も、中身はこの数値です。

つまり!!
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)
このようになるはずです。
4. 埋め込みにして整形する
見やすく整形しましょう。
ここからは初心者向けではありません。
8~11行目のpath と name はそれぞれjsonファイルの配置場所とjsonファイル名です。データベースとして使います。
12~15行目のtargetServer と targetLogChannel は通話を監視するサーバーの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);
ちゃんと動けばこのようになります。
(ComponentsV2を使っているのは私が使ってみたかったからです。)
5. おわり
昔書いたコードを今の私ならどうするかと思い、どうせなら記事にしてみました。
プログラミングの勉強としてちょうどよかった記憶があるので、ぜひ糧にしてください。


