DiscordのアクティビティステータスをGoogleカレンダーに記録して、ゲームプレイ時間を把握しよう！

# 概要
この記事では、Discordのアクティビティステータスを使って、ゲームプレイ時間をGoogleカレンダーに記録する方法をご紹介します。

ゲーム開発エンジニアだけではなく、ゲームが好きなエンジニアが集まる株式会社エイプリルナイツでは、雑談で「いま何のゲームをやってる？」や、社内アンケートで「一日のプレイ時間は？」という質問がよく出ます。
これまではなんとなくで答えていたものの、正確にプレイ時間を把握できれば、これらの質問に即答できます。
過去のアンケート結果の公開記事がこちらです。

https://akspot.game/?p=9504

また、定期的に自分のプレイ時間を振り返り、限られた時間をどのぐらいゲームに使うべきかを日々考える必要があるかもしれません。

ちなみに、Discordアクティビティはゲーム以外の様々なアプリにも対応しているので、Spotifyをアクティビティに表示させれば音楽ログとして、VSCodeの拡張機能を導入すれば開発ログとしても活用できます。
Spotifyの場合は曲名とアーティスト名を取得して記録するようにしています。

***

発想のきっかけはこちらのSteamのアクティビティを記録するという試みでした。

https://blog.kunst1080.net/post/2021/05/28/gas-steam%E3%81%A7%E9%81%8A%E3%82%93%E3%81%A0%E6%99%82%E9%96%93%E3%82%92google%E3%82%AB%E3%83%AC%E3%83%B3%E3%83%80%E3%83%BC%E3%81%AB%E8%A8%98%E9%8C%B2%E3%81%99%E3%82%8B/

これでもいいけど、Steamじゃないゲームもたくさんあるので、どうせならすべて記録したい！ということでDiscordの「〜をプレイ中」の部分を記録できないかと考えました。探してみるとDiscordのアクティビティを取得してSlackのStatusに反映してる方がいたので、あとはこの2つの記事を組み合わせるだけですね。

https://qiita.com/nick-worker/items/9613e0b726c10daba076

## 構成図
GAS(GoogleAppsScript)は1分ごとのトリガーを設定し、取得したアクティビティをスプレッドシートに保存します。
1分後の実行時には直前のアクティビティと同じかどうかで新しくカレンダーに予定を登録するか、終了時間を1分延長するかのどちらかを判定するのに利用します。
```mermaid
sequenceDiagram
    participant Googleカレンダー
    participant スプレッドシート
    participant GAS
    participant API
    participant DiscordBot

    GAS->>API: アクティビティを確認する<br>(1分ごとのトリガー)
    API->>DiscordBot: アクティビティを確認する
    DiscordBot->>API: アクティビティを返す
    API->>GAS: アクティビティを返す
    GAS->>Googleカレンダー: カレンダーにアクティビティを登録・更新する<br>(シートを参照して変わってなければ時間延長)
    GAS->>スプレッドシート: シートに現在のアクティビティを<br>保存する

```

## 完成イメージ
 - Googleカレンダーを開くとこのような感じで記録されてます。
 - 実はDiscordのアクティビティステータスはゲーム以外のアプリケーションも連携させることができるため、SpotifyやVSCodeを記録することができます。ステータスから何の曲を聞いてるか、どんな言語のファイルを開いているか、デバッグしているのかみたいな情報を取得できるので細かく記録することもできます。
 - VSCodeのアクティビティは起動したままスリープしたらずっと記録されてしまったため削除してます
   - アイドリング状態の場合は記録しないなどの対応を入れるといいかもしれません。
:::note warn
本当に社会人なのか？という時間までゲームしてる記録があったので一部分のみです。すべてをお見せすることができず大変残念です。
:::
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/262707/89ce5e6f-1020-95de-7929-ccb446d7cc69.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/262707/8858b18d-b5b2-9428-4145-569cc889fa47.png)
- スケジュールビューの方が見やすいかもしれません
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/262707/9ac93a15-a9c0-eb1f-cfde-2b413b37c00e.png)

# 実装のポイント
## Discordアクティビティステータスの取得方法
   - discord.jsを使用したアクティビティステータスの取得部分
   - userIdを指定してactivitiesを取得します。Discordには1つしか表示されませんが、Spotifyを聞きながらVSCodeを立ち上げている場合には複数取得できます。
```js
async function getActivity(userId) {
  return new Promise((resolve, reject) => {
    client.guilds.cache.forEach(async (guild) => {
      const member = await guild.members.fetch(userId);
      if (member) {
        resolve(member.presence.activities);
      } else {
        reject(new Error('User not found'));
      }
    });
  });
}
```

## Googleカレンダーへのアクティビティステータスの記録手順
   - Node.js+expressでAPI化し、アクティビティステータスをGASに送信
```js
app.get('/activity/:userId', async (req, res) => {
  try {
    const activity = await getActivity(req.params.userId);
    res.json(activity);
  } catch (err) {
    res.status(404).json({ error: err.message });
  }
});
```
  - GASでGoogleカレンダーにアクティビティステータスを記録
  - APIからjsonを取得し、ゲームタイトルやアクティビティの開始時間を取得します。
:::note info
コード全体は後半に載せてます
:::
```js
const api = new DiscordActivityApi();
const json = api.getUserActivity(DISCORD_USER_ID);

const gameid = json[i].createdTimestamp;
let title = json[i]["name"];
main(gameid, title, row, cache, cacheData);


function main(gameid, title, row, cache, cacheData) {  
    const calendarService = new CalendarService(CALENDAR_ID);
    const current = cacheData.find((d) => d.title == title);
    // 同じタイトルが続いていればカレンダーの時間を1分延長し、異なれば新しい予定として登録
    if (current) {
        Logger.log("update");
        current.endTime = new Date();
        calendarService.updateEventEndTime(current.calendarEventId, current.endTime);
    } else {
        createNewEvent(gameid, title, calendarService, cache, row);
    }
}

```

- アクティビティが何かしらあるときのスプレッドシート
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/262707/774ee255-7853-2d6e-2379-288e3261167b.png)


# ソースコード全体や細かい設定部分
## DiscordBotとGoogleAppsScriptの役割と設定方法
### DiscordBotの作成と設定
DiscordBotの基本的な作成方法は既にたくさんの情報があるので、discord.js v14以降に対応した方法をググってみてください。
DiscordBotの役割とコードについて説明します。
#### DiscordBotの役割
DiscordBotのソースコードではありますが、expressでAPIも作ります。
GASからAPIを呼び出すと、APIがDiscordBotを通してIDで指定したユーザーのアクティビティを取得するような仕組みです。

DiscordBotは自分が所属するサーバーに導入してください。どこか1つに導入されていれば問題ないので、自分とBotだけのサーバーを作るのがいいと思います。

#### ソースコード
```js
const Discord = require('discord.js');
const GatewayIntentBits = require('discord.js').GatewayIntentBits;
const
  Partials
    = require('discord.js');
const client = new Discord.Client({
  intents: [
    GatewayIntentBits.Guilds,
    GatewayIntentBits.GuildPresences,
    GatewayIntentBits.GuildMembers,
  ],
  partials: [Partials.User, Partials.GuildMember],
});

require('dotenv').config();
client.login(process.env.DISCORD_BOT_TOKEN);
client.on('ready', () => {
  console.log(`Logged in as ${client.user.tag}!`);
});

async function getActivity(userId) {
  return new Promise((resolve, reject) => {
    client.guilds.cache.forEach(async (guild) => {
      const member = await guild.members.fetch(userId);
      if (member) {
        resolve(member.presence.activities);
      } else {
        reject(new Error('User not found'));
      }
    });
  });
}

// expressをインポート
const express = require('express');
// expressアプリケーションを生成
const app = express();

// GETリクエスト用のエンドポイント
app.get('/activity/:userId', async (req, res) => {
  try {
    const activity = await getActivity(req.params.userId);
    res.json(activity);
  } catch (err) {
    res.status(404).json({ error: err.message });
  }
});

const port = process.env.PORT || 3000;
app.listen(port, () => {
  console.log(`listening at ${port}`);
});

```


### GoogleAppsScriptの作成と設定
#### プロパティの設定
適当なスプレッドシートからスクリプトエディタを開いてGoogleAppsScriptのエディタを開きます。
左サイドバーの一番下にある「プロジェクトの設定」を開き、プロパティを設定します。
|プロパティ|値|
|--|--|
|CALENDAR_ID|記録したいGoogleカレンダーのID|
|DISCORD_API_URL|`https://hogehoge/activity`|
|DISCORD_USER_ID|開発者モードをONにして取得できる数字のみのID|

`DISCORD_API_URL` はDiscordBotをデプロイした環境によって代わります。

#### トリガーの設定
左サイドバーのトリガーを開き、右下のボタンから追加します。
時間主導で1分おきにしておきます。

#### ソースコード
メインとなるソースコードです。

```js
const CALENDAR_ID = PropertiesService.getScriptProperties().getProperty("CALENDAR_ID")
const DISCORD_API_URL = PropertiesService.getScriptProperties().getProperty("DISCORD_API_URL")
const DISCORD_USER_ID = PropertiesService.getScriptProperties().getProperty("DISCORD_USER_ID")
function run() {
  const api = new DiscordActivityApi();
  const json = api.getUserActivity(DISCORD_USER_ID);
  const numRows = json.length > 0 ? json.length : 10;
  const cache = new SheetRangeCacheService(2, numRows, 5, new GameEventConverter()); // 行数をセット
  if (json.length == 0) {
    cache.clear();
    return;
  }
  const startRow = 2;
  const cacheData = cache.restore(startRow, numRows);
  for (let i = 0; i < numRows; i++) {
    const row = startRow + i;
    const gameid = json[i].createdTimestamp;
    let title = json[i]["name"];
    // Spotifyの場合だけ、曲名/アーティスト名を記録するようにしています
    if (title === "Spotify") {
      title = `Listening to Spotify "${json[i].details} / ${json[i].state}"`;
    }
    main(gameid, title, row, cache, cacheData);
  }
}

function main(gameid, title, row, cache, cacheData) {
  if (!title) {
    Logger.log("clear");
    cache.clear();
    return;
  }
  Logger.log({ gameid });
  Logger.log({ title });

  const calendarService = new CalendarService(CALENDAR_ID);
  const current = cacheData.find((d) => d.title == title);

  if (current) {
    Logger.log("update");
    current.endTime = new Date();
    calendarService.updateEventEndTime(current.calendarEventId, current.endTime);
  } else {
    createNewEvent(gameid, title, calendarService, cache, row);
  }
}

function createNewEvent(gameid, title, calendarService, cache, row) {
  Logger.log("new");
  const startTime = new Date();
  const endTime = new Date();
  const calendarEventId = calendarService.createEvent(title, startTime, endTime);
  const newData = new GameEvent(gameid, title, calendarEventId, startTime, endTime);
  cache.store(newData, row);
}

```

#### メインで参照しているその他のクラスのソースコード
すべて同じファイルに書いてしまっても大丈夫です。

<details><summary>GameEventクラス</summary>

```js
class GameEvent {
  constructor(gameid, title, calendarEventId, startTime, endTime) {
    this.gameid = gameid;
    this.title = title;
    this.calendarEventId = calendarEventId;
    this.startTime = startTime;
    this.endTime = endTime;
  }
}

class GameEventConverter {
  toArray(gameEvent) {
    return [
      gameEvent.gameid,
      gameEvent.title,
      gameEvent.calendarEventId,
      gameEvent.startTime,
      gameEvent.endTime
    ];
  }

  fromArray(arr) {
    return new GameEvent(arr[0], arr[1], arr[2], arr[3], arr[4]);
  }
}
```

</details>

<details><summary>CalendarServiceクラス</summary>

```js
class CalendarService {
  constructor(calendarId) {
    this.calendar = CalendarApp.getCalendarById(calendarId);
  }

  createEvent(title, startTime, endTime) {
    const res = this.calendar.createEvent(title, startTime, endTime);
    const eventId = res.getId();
    return eventId;
  }

  updateEventEndTime(eventId, endTime) {
    const event = this.calendar.getEventById(eventId);
    const startTime = event.getStartTime();
    event.setTime(startTime, endTime);
  }

  getCalendar() {
    return this.calendar;
  }
}
```

</details>

<details><summary>DiscordActivityApiクラス</summary>

```js

class DiscordActivityApi {
  getUserActivity(userId) {
    const url = DISCORD_API_URL + userId;
    const options = {
      "muteHttpExceptions": true
    }
    const res = UrlFetchApp.fetch(url, options);
    if (res.getResponseCode() === 200) {
      const content = res.getContentText();
      const json = JSON.parse(content);
      return json;
    } else {
      console.error(`${res.getResponseCode()}: ${res.getContentText()}`);
      return {};
    }
  }
}

```
</details>

<details><summary>SheetRangeCacheServiceクラス</summary>

```js

class SheetRangeCacheService {
  constructor(startRow, numRows, rangeWidth, converter) {
    this.startRow = startRow;
    this.numRows = numRows;
    this.range = SpreadsheetApp.getActiveSheet().getRange(startRow, 1, numRows, rangeWidth); // 指定された行と列の範囲を取得
    this.converter = converter;
  }

  // スプレッドシートにキャッシュを保存
  store(obj, row) {
    const data = this.converter.toArray(obj);
    const currentValues = this.range.getValues();
    const values = currentValues.map((row) => ([...row]));
    values[row - this.startRow] = data;
    this.range.setValues(values);
  }

  // 指定された行から指定された件数分のデータを復元して返す
  restore(startRow, numRows) {
    const data = this.range.getValues().slice(startRow - this.startRow, numRows);
    const values = Array(numRows).fill("");
    return values.map((_, i) => this.converter.fromArray(data[i] || values));
  }

  // キャッシュをクリア
  clear() {
    this.range.clearContent();
    this.range.clear({ contentsOnly: true, skipFilteredRows: true, skipHiddenRows: true }); // 元のコードにはなかった追加の処理
  }
}
```
</details>

## まとめと今後の活用方法
### Discordアクティビティステータスの記録による正確なゲームプレイ時間の把握の重要性
この記事で紹介した内容は、Discordのアクティビティステータスを「時間管理のツール」として新しく使う方法についてです。ゲームプレイ時間の正確な把握は、私たちの日々の生活や作業にどれだけゲームが影響を与えているかを理解し、時間の管理やプライオリティの設定を改善するために重要です。

また、上記のゲームプレイ以外にも、SpotifyやVSCodeなど他のアプリに関するアクティビティも管理することが可能です。これにより、音楽聴取時間や開発作業時間を可視化することで、それぞれのアクティビティに対する意識を高め、より効率的な時間の使い方を模索することが可能です。

### 今後の活用方法と改善の可能性について考察
しかしながら、このシステムはまだ発展途上にあり、さらなる改善の余地が広がっています。例えば、アクティビティごとのカテゴリ分けや、より詳細な活動内容を記録するための機能拡張などを考えることができます。

GoogleカレンダーからデータをエクスポートしGoogle Looker Studio（旧データポータル）でデータ分析してみるのもいいかもしれません。

最終的には、このような技術を活用することで、私たち一人一人の日々の活動をより良く理解し、生産性を向上させることが目標です。我々エンジニアにとって、ゲームだけでなく、様々な作業時間を正確に把握し、管理する能力は不可欠であるといえるでしょう。

今回はその一例として、Discordのアクティビティステータスを活用したゲームプレイ時間の管理方法をご紹介しましたが、その他のアプリやタスク管理のツールと連携した活用方法など、さらなる発展や改善を期待しています。


### おまけ
OuraRingという指輪型ウェアラブルデバイスをご存知でしょうか？OuraRingユーザーにはAPIが開放されているため、毎日の睡眠スコアをGoogleカレンダーに記録することもできます。
睡眠時間とスコアがパッと見てわかるので、ちゃんと寝ればスコアが上がるという当たり前のことが予定を確認するついでに再認識できてよいです。
https://tech.affordigitalife.com/oura-api-sleep-log/