はじめに
こんにちは!この記事では、Discord朝活の参加状況を自動記録・可視化するシステム「Morning Winner」を作った経緯と技術的な実装について解説します。
この記事で得られること
- Discord Botでボイスチャンネルの参加を監視する方法
- Google Sheets APIを使ったデータ保存の実装
- Google Apps Scriptでダッシュボードを作る方法
- 完全無料でサーバーレスなシステムの構築方法
想定読者
- Discord Botを作ってみたい方
- Google API(Sheets API)を使ってみたい方
- 朝活やチーム活動の継続に課題を感じている方
前提知識
- Node.js の基本的な知識
- Discord の基本的な使い方
- Google アカウント
概要・背景
朝活が続かない問題
毎日Discordで朝活をしていました。朝7時に起きて勉強しようという約束だったのですが、なかなかみんな起きてこない。朝活存続の危機でした。
そこで、Carl-bot の Logging 機能を導入して、誰がいつボイスチャンネルに入って、いつ抜けたのかを記録することにしました。すると不思議なことに、みんな遅刻してもボイスチャンネルに入ってくるようになったのです。
定量化の力
この経験から、「定量化が習慣継続に重要」だと実感しました。しかし、Carl-botのログは見づらく、データを活用しづらい状態でした。
そこで、以下の機能を持つアプリを作ることにしました:
- ボイスチャンネル参加状況の自動記録
- リアルタイムダッシュボードでの可視化
- 参加時間ランキング
- GitHub風のアクティビティヒートマップ
プロジェクト名は「Morning Winner」。朝活(Morning)と朝勝つ(Winner)をかけています😊
システム構成
アーキテクチャ
Discord Bot (Node.js)
↓ ボイスチャンネル参加を監視
↓
Google Sheets (データベース)
↓ データを保存
↓
Google Apps Script (可視化)
→ Webダッシュボードを表示
技術スタック
- Discord Bot: Discord.js v14
- データベース: Google Sheets
- 可視化: Google Apps Script + HTML/CSS/JavaScript
- ランタイム: Node.js 18+
- 日時処理: dayjs
なぜこの構成にしたのか
完全無料で運用できることを最優先にしました。
- Supabaseなどの外部DBサービスは不要
- Google Sheets/GAS は無料枠で十分
- サーバーレスなので保守コストも低い
実装
1. Discord Botでボイスチャンネルを監視
Discord.js の voiceStateUpdate イベントを使って、ボイスチャンネルの参加・退出を検知します。
import { Client, GatewayIntentBits } from 'discord.js';
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildVoiceStates,
GatewayIntentBits.GuildMembers,
],
});
client.on('voiceStateUpdate', async (oldState, newState) => {
const targetChannelId = process.env.VOICE_CHANNEL_ID;
// ボイスチャンネルに参加した場合
if (!oldState.channel && newState.channel?.id === targetChannelId) {
const joinedAt = new Date();
console.log(`${newState.member.displayName} が参加しました`);
// Google Sheets に記録
await logActivity(newState.member, joinedAt, null);
}
// ボイスチャンネルから退出した場合
if (oldState.channel?.id === targetChannelId && !newState.channel) {
const leftAt = new Date();
console.log(`${oldState.member.displayName} が退出しました`);
// Google Sheets に記録を更新
await updateActivity(oldState.member, leftAt);
}
});
client.login(process.env.DISCORD_TOKEN);
2. Google Sheets APIでデータを保存
Google Sheets を簡易的なデータベースとして利用します。
import { google } from 'googleapis';
import { readFile } from 'fs/promises';
// 認証情報の読み込み
const credentials = JSON.parse(
await readFile('./credentials.json', 'utf-8')
);
// Google Sheets API クライアントの初期化
const auth = new google.auth.GoogleAuth({
credentials,
scopes: ['https://www.googleapis.com/auth/spreadsheets'],
});
const sheets = google.sheets({ version: 'v4', auth });
// データ記録関数
async function logActivity(member, joinedAt, leftAt) {
const spreadsheetId = process.env.SPREADSHEET_ID;
const values = [[
member.id,
member.displayName,
joinedAt.toISOString(),
leftAt ? leftAt.toISOString() : '',
leftAt ? calculateDuration(joinedAt, leftAt) : '',
]];
await sheets.spreadsheets.values.append({
spreadsheetId,
range: 'ActivityLog!A:E',
valueInputOption: 'USER_ENTERED',
resource: { values },
});
}
3. Google Apps Scriptでダッシュボードを作成
Google Apps Script を使って、Sheets のデータを Web ダッシュボードとして公開します。
Code.gs:
function doGet() {
return HtmlService.createHtmlOutputFromFile('Dashboard')
.setTitle('Morning Winner Dashboard')
.setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}
function getActivityData() {
const sheet = SpreadsheetApp.getActiveSpreadsheet()
.getSheetByName('ActivityLog');
const data = sheet.getDataRange().getValues();
// ヘッダー行を除く
const rows = data.slice(1);
return rows.map(row => ({
userId: row[0],
userName: row[1],
joinedAt: row[2],
leftAt: row[3],
duration: row[4],
}));
}
Dashboard.html:
<!DOCTYPE html>
<html>
<head>
<title>Morning Winner Dashboard</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 16px;
padding: 30px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}
</style>
</head>
<body>
<div class="container">
<h1>🌅 Morning Winner Dashboard</h1>
<div id="stats"></div>
<canvas id="chart"></canvas>
<div id="ranking"></div>
</div>
<script>
google.script.run.withSuccessHandler(renderDashboard).getActivityData();
function renderDashboard(data) {
// 統計情報の表示
renderStats(data);
// グラフの描画
renderChart(data);
// ランキングの表示
renderRanking(data);
}
</script>
</body>
</html>
4. データ集計とランキング
参加時間を集計してランキングを表示します。
function renderRanking(data) {
// ユーザーごとに参加時間を集計
const userStats = {};
data.forEach(record => {
if (!record.duration) return;
if (!userStats[record.userName]) {
userStats[record.userName] = {
totalMinutes: 0,
sessions: 0,
};
}
userStats[record.userName].totalMinutes += record.duration;
userStats[record.userName].sessions += 1;
});
// ランキング順にソート
const ranking = Object.entries(userStats)
.map(([name, stats]) => ({
name,
totalMinutes: stats.totalMinutes,
sessions: stats.sessions,
avgMinutes: stats.totalMinutes / stats.sessions,
}))
.sort((a, b) => b.totalMinutes - a.totalMinutes);
// HTML生成
const html = ranking.map((user, index) => `
<div class="rank-item">
<span class="rank-number">${index + 1}</span>
<span class="user-name">${user.name}</span>
<span class="total-time">${Math.floor(user.totalMinutes / 60)}h ${user.totalMinutes % 60}m</span>
</div>
`).join('');
document.getElementById('ranking').innerHTML = html;
}
よくあるトラブルと対処法
トラブル1: Bot が起動しない
原因: 環境変数の設定ミス
解決策:
# .env ファイルを確認
cat .env
# 必要な変数が全て設定されているか確認
# - DISCORD_TOKEN
# - VOICE_CHANNEL_ID
# - SPREADSHEET_ID
トラブル2: Sheets に記録されない
原因: Service Account の共有設定が不足
解決策:
-
credentials.jsonのclient_emailをコピー - Google Sheets の「共有」から編集権限を付与
- シート名が
ActivityLogになっているか確認
トラブル3: ダッシュボードが表示されない
原因: Apps Script のデプロイ設定ミス
解決策:
- 「アクセスできるユーザー」を「全員」に設定
- 「次のユーザーとして実行」を「自分」に設定
まとめ
この記事では、Discord朝活の参加状況を可視化する「Morning Winner」の実装について解説しました。
要点:
- Discord.js で
voiceStateUpdateイベントを監視 - Google Sheets API でデータを保存
- Google Apps Script で Web ダッシュボードを公開
- 完全無料でサーバーレスなシステムを構築
朝活や勉強会など、チームでの習慣継続に課題を感じている方の参考になれば嬉しいです!