Gatherのオンライン数をSlackへ投稿するBotを作ってCloudRunで動かしてみる

## はじめに

私のある勤め先では [Google CloudRun](https://cloud.google.com) (GCR) を使っております。
その上、スキルアップ・福利厚生・特定の業務中の検証環境を兼ねて [Google Cloud](https://cloud.google.com) (GC) の課金済の無料枠も全エンジニアに設定されています ✨️
CI/CD がしっかりしているおかげか、職場としては GCR を使っているものの私個人としてはあまり使い方をわかっていないサービスなので今回の三連休を活用してキャッチアップしてみることにしました 🙌

---

## 作ったもの

[Gather](https://ja.gather.town/) のオンライン数を [Slack](http://slack.com/) へ投稿する Bot を作ってみました！

今回はこちらの実装とデプロイまでのステップを紹介します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/133081/ea090893-074b-4b95-96ea-4df3f561e1d0.png)

_※Gather はロールプレイングゲームのような UI を持つバーチャルオフィスサービスの1つです🎮️_
_(余談1: 本当はゲームみたいに「〇〇さんがオンライン/オフラインになりました！」とリアルタイムな通知を出してみたかったのですが社内の個人に割り当てられた GC の枠で常時起動しているものを動かすのは厳しそうだったので諦めました🙏)_
_(余談2: オンライン数を定期的に投稿するだけならサーバレス利用がOKになった(らしい) [GitHub Actions](https://github.co.jp/features/actions) の Cron スケジュールイベントの機能で作っても良かったかも)_

https://ja.gather.town/

---

## 【1】 ローカルでの開発の準備

### 以下の `package.json` を用意し `npm install`

今回は Node.js を使います。

_※`tsconfig.json`や`biome.json`の内容はお好みで(`tsc`の出力先は`dist`ディレクトリになるようにしてください)_

```json
{
  "name": "gather-online-counter-on-slack",
  "version": "1.0.0",
  "description": "Gather Town のオンライン数を Slack へ投稿します",
  "keywords": [
    "gather-town",
    "slack"
  ],
  "main": "./dist/index.js",
  "scripts": {
    "build": "tsc",
    "lint:fix": "npx @biomejs/biome check --write --unsafe ./src",
    "lint:pkg": "npx sort-package-json",
    "quick": "ts-node ./src/index.ts",
    "start": "node ./dist/index.js",
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "dependencies": {
    "@gathertown/gather-game-client": "^43.0.1",
    "dotenv": "^16.4.7",
    "isomorphic-ws": "^5.0.0",
    "typescript": "^5.7.3"
  },
  "devDependencies": {
    "@biomejs/biome": "^1.9.4",
    "sort-package-json": "^2.14.0",
    "ts-node": "^10.9.2"
  }
}
```

### `Dockerfile` と `docker-compose.yaml` を準備

GCR では 8080 ポートを使います。

```Dockerfile
FROM node:23-alpine3.20

WORKDIR /app
COPY package.json /app/package.json
COPY package-lock.json /app/package-lock.json
COPY tsconfig.json /app/tsconfig.json
COPY src/ /app/src

RUN npm i && npm run build
CMD ["npm", "start"]
```

```yaml
version: '3'
services:
  app:
    build:
      dockerfile: Dockerfile
      context: ./
    volumes:
      - ./:/app
    ports:
      - "8080:8080"
    env_file:
      - .env
    tty: true
    stdin_open: true
```

---

## 【2】 各種サービスの API トークンなどを準備・確認する

確認した内容は `env` ファイルにまとめてください

### GCR

GC の今回使う `PROJECT_ID` を確認し手元にひかえてください

### Slack

1. `Automations` を開く
    - ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/133081/c65a701a-2bff-4a55-b98b-aa628be0ed0f.png)
2. `New Workflow` を押す
    - ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/133081/b2285a80-f1ce-444f-a862-195dcf1fbb30.png)
3. `count` プロパティの内容を Slack チャンネルに投稿できる Workflow を作成し、 `Web request URL` をコピーし手元にひかえてください
    - ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/133081/1886b2b4-3c62-4c1e-a36a-3f13b9385c8f.png)
    - ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/133081/5479bbe0-0849-45fa-9882-d91ee5843de3.png)

### Gather

#### API キー

https://app.gather.town/apikeys にて取得し手元にひかえてください

#### スペースID と スペース名

対象の Gather スペースの URL の内、以下の部分がそれぞれ スペースID と スペース名 であるため手元にひかえてください

`https://app.gather.town/app/GATHER_SPACE_ID/GATHER_SPACE_NAME`

### 最終的にできあがる `env` ファイル (例)

```.env
# app (後述のコードで使います)
PORT="8080"
UUID="" # 適当に生成し設定してください

# Slack の Workflow から webhook トリガーのものを作成し count ペイロードを受け取って表示できるようにしてください 例: https://hooks.slack.com/triggers/...
SLACK_WEBHOOK_URL=""

# https://app.gather.town/apikeys
GATHER_API_KEY=""

# https://app.gather.town/app/GATHER_SPACE_ID/GATHER_SPACE_NAME
GATHER_SPACE_ID=""
GATHER_SPACE_NAME=""
```

---

## 【3】 実装

`src` ディレクトリを用意し、その中に `index.ts` ファイルを作成して以下のコードを書いて保存してください。
また `npm run build` できるか確認してください。

```typescript
import http from 'node:http';
import { Game } from '@gathertown/gather-game-client';
import dotenv from 'dotenv';
import isomorphicWS from 'isomorphic-ws';
global.WebSocket = isomorphicWS;

const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));

const STAGE = String(process.env.STAGE).toLocaleLowerCase();
if (STAGE === 'local') {
  dotenv.config({ path: '.env' });
}

// app
const PORT = process.env.PORT || 8080;
const UUID = process.env.UUID || '';

// gather
const GATHER_API_KEY = process.env.GATHER_API_KEY || '';
const GATHER_SPACE_ID = process.env.GATHER_SPACE_ID || '';
const GATHER_SPACE_NAME = process.env.GATHER_SPACE_NAME || '';
const GATHER_SPACE_INFO = `${GATHER_SPACE_ID}\\${GATHER_SPACE_NAME}`;

// slack webhook url
const SLACK_WEBHOOK_URL = process.env.SLACK_WEBHOOK_URL || '';

if (!UUID) {
  console.error('UUID is not set');
  process.exit(1);
}

if (!GATHER_API_KEY || !GATHER_SPACE_ID || !GATHER_SPACE_NAME) {
  console.error('GAME_API_KEY, GAME_SPACE_ID, and GAME_SPACE_NAME must be provided from environment variables');
  process.exit(1);
}

if (!SLACK_WEBHOOK_URL) {
  console.error('SLACK_WEBHOOK_URL must be provided from environment variables');
  process.exit(1);
}

http
  .createServer((req, res) => {
    // 念の為簡易に受け付けるリクエストを制限する
    if (req.url === `/count/${UUID}`) {
      res.writeHead(202, { 'Content-Type': 'application/json' });
      res.end(`{ message: "request received to count gather online users" }\n`);

      const gather = new Game(GATHER_SPACE_INFO, () => Promise.resolve({ apiKey: GATHER_API_KEY }));
      gather.subscribeToConnection((connection) => {
        if (!connection) {
          return;
        }
        countAndReport(gather);
      });
      gather.connect();
    } else {
      res.writeHead(200, { 'Content-Type': 'text/html' });
      res.end('<h1>gather-online-counter-on-slack</h1>\n');
    }
  })
  .listen(PORT, () => {
    console.log(`[LOG] Server running at http://localhost:${PORT}/`);
  });

const countAndReport = async (gather: Game) => {
  while (!Object.keys(gather.players).length) {
    await wait(100);
  }
  const countOfPlayers = Object.keys(gather.players).length;
  gather.disconnect();

  // Slack の Webhook に対して count: countOfPlayers を fetch で送信する
  const payload = {
    count: `${countOfPlayers}`,
  };
  await fetch(SLACK_WEBHOOK_URL, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(payload),
  });
  console.log(`[LOG] gather online users: ${countOfPlayers}`);
};
```

---

## 【4】 `docker compose up --build`

最終的にこんな感じで起動すれば OK です :tada: 

```zsh
[+] Running 1/1
 ✔ Container bot  Recreated                                                                                                                                   0.1s 
Attaching to app-1
app-1  | 
app-1  | > gather-online-counter-on-slack@1.0.0 start
app-1  | > node ./dist/index.js
app-1  | 
[LOG] Server running at http://localhost:8080/
```

---

## 【5】 デプロイ

### `cloudbuild.yaml` を作成

_※今回、アプリケーション名とタグ名は`gather-online-counter-on-slack/development`_
_※このファイルで今回主に作成するべきファイルは以上です！より詳細にファイル構造がわかるよう後日GitHubなどで公開できればと思います:pray:_

```
steps:
  - name: "gcr.io/cloud-builders/docker"
    args:
      [
        "build",
        "-f",
        "Dockerfile",
        "-t",
        "gcr.io/$PROJECT_ID/gather-online-counter-on-slack/development",
        ".",
      ]
images:
  - "gcr.io/$PROJECT_ID/gather-online-counter-on-slack/development"
```

### `gcloud builds submit` を実行（イメージを GCR 上に準備）

実行後、ビルドの経過がターミナル上に表示されるのでしばらくお待ち下さい

```zsh
gcloud builds submit --project={【2】で準備したGCのPROJECT_ID} --config cloudbuild.yaml
```

### GCR へデプロイ :tada: 

```zsh
gcloud run deploy gather-online-counter-on-slack --project={【2】で準備したGCのPROJECT_ID} --image="gcr.io/{【2】で準備したGCのPROJECT_ID}/gather-online-counter-on-slack/development" --platform=managed --region=asia-northeast1 --allow-unauthenticated --set-env-vars "UUID={適当に生成}" --set-env-vars "GATHER_API_KEY={Gatherから}" --set-env-vars "GATHER_SPACE_ID={Gatherから}" --set-env-vars "GATHER_SPACE_NAME={Gatherから}" --set-env-vars "SLACK_WEBHOOK_URL={Slackから}"
```

::: note
- `--set-env-vars "KEY1=VAL1"` で環境変数を設定できます
- `--allow-unauthenticated` でとくに認証なく `8080` ポートで動いているアプリケーションへアクセスできます
:::

| ※上手くデプロイできればこのような表示になるはず |
| - |
| `https://console.cloud.google.com/run?hl=ja&project={【2】で準備したGCのPROJECT_ID}` |
| ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/133081/841bb79e-e490-49b7-b975-2c6c2eef87a2.png) |

### 動作確認

GC の画面から URL `https://???.asia-northeast1.run.app` をコピーし末尾に `/count/【2】で用意したUUID` の URL` を書き加えてブラウザなどで開いてください。
このような画面が表示されしばらくすると、 Slack へ冒頭で紹介したような通知がながれるかと思います。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/133081/5f9324b1-16ac-4f30-bb49-82ea27fcd6ab.png)

あとは uptimerobot とかでお好みの間隔で定期的に URL に対してリクエストされるようにすることで Slack へ定期的に Gather のオンライン数が投稿されます :robot: :tada: 

https://uptimerobot.com/

---

## さいごに

今回は GCR でデプロイしたいものの実装〜デプロイまでできて良かったです！
思ったよりも使いやすかったので、これからも GCR や Gather/Slack などの API その他でいろいろ作っていければと思います :muscle: 
ここまで、ご高覧ありがとうございました！