1
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】朝活を継続させるための定量化アプリをNode.js + Google Apps Scriptで作った話

Posted at

はじめに

こんにちは!この記事では、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 の共有設定が不足

解決策:

  1. credentials.jsonclient_email をコピー
  2. Google Sheets の「共有」から編集権限を付与
  3. シート名が ActivityLog になっているか確認

トラブル3: ダッシュボードが表示されない

原因: Apps Script のデプロイ設定ミス

解決策:

  • 「アクセスできるユーザー」を「全員」に設定
  • 「次のユーザーとして実行」を「自分」に設定

まとめ

この記事では、Discord朝活の参加状況を可視化する「Morning Winner」の実装について解説しました。

要点:

  • Discord.js で voiceStateUpdate イベントを監視
  • Google Sheets API でデータを保存
  • Google Apps Script で Web ダッシュボードを公開
  • 完全無料でサーバーレスなシステムを構築

朝活や勉強会など、チームでの習慣継続に課題を感じている方の参考になれば嬉しいです!

参考資料

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