Discordjs v14をGAEで動かす

DiscorbボットをGAE(Google App Engine)上で構築する時のtips。

Discordボットは基本的にスラッシュコマンド(Interaction)を駆使して運用するのがベターですが、今回は監視が必要だったのでWebSocketで常時リッスンします。

### 使用技術
- Node.js v20
- discord.js: ^14
- GCP

```javascript:index.js
require("dotenv").config();
require("./deploy-commands.js").deployCommands();
const { Client, Events, GatewayIntentBits, Partials } = require("discord.js");
const tallyFile = require("./commands/tally.js");
const http = require('http');

const server = http.createServer((_req, res) => {
  res.writeHead(200, {'Content-Type': 'text/plain'});
  res.end('Bot実行中です');
});

const PORT = process.env.PORT || 8080;
server.listen(PORT, () => {
  console.log(`サーバーが起動しました PORT: ${PORT}`);
});

const client = new Client({
  intents: [
    GatewayIntentBits.DirectMessages,
    GatewayIntentBits.Guilds,
    GatewayIntentBits.GuildMembers,
    GatewayIntentBits.GuildMessages,
    GatewayIntentBits.MessageContent,
  ],
  partials: [Partials.Message, Partials.Channel],
});

client.once(Events.ClientReady, (c) => {
  console.log(`準備OKです! ${c.user.tag}がログインします。`);
});

client.on(Events.InteractionCreate, async (interaction) => {
  if (!interaction.isChatInputCommand()) return;
  if (interaction.commandName === tallyFile.data.name) {
    try {
      await tallyFile.execute(interaction);
    } catch (error) {
      console.error(error);
      if (interaction.replied || interaction.deferred) {
        await interaction.followUp({
          content: "コマンド実行時にエラーになりました。",
          ephemeral: true,
        });
      } else {
        await interaction.reply({
          content: "コマンド実行時にエラーになりました。",
          ephemeral: true,
        });
      }
    }
  } else {
    console.error(
      `${interaction.commandName}というコマンドには対応していません。`
    );
  }
});

client.on("messageCreate", async (message) => {
  // add
});

client.login("TOKEN");

```

```javascript:deploy-commands.js
const { REST, Routes } = require("discord.js");
const hoge = require("./commands/hoge.js");

const commands = [hoge.data.toJSON()];
const rest = new REST({ version: "10" }).setToken("token");

const deployCommands = async () => {
  try {
    await rest.put(Routes.applicationGuildCommands("applicationId", "guildId"), {
      body: commands,
    });
    console.log("サーバー固有のコマンドが登録されました！");
  } catch (error) {
    console.error("コマンドの登録中にエラーが発生しました:", error);
  }
};

module.exports = { deployCommands };

```

```yaml:app.yaml
runtime: nodejs20
env: standard
instance_class: F1
handlers:
  - url: .*
    script: auto
env_variables:
  # 環境変数
```

## tips1: WEBページが必須
GAEの本来の用途はWebのホスティングです。
そのため、HTTPリクエストに対してレスポンス200のお返事ができないとエラーを吐かれてシャットダウンします。
これについては、
```javascript
const http = require('http');

const server = http.createServer((_req, res) => {
  res.writeHead(200, {'Content-Type': 'text/plain'});
  res.end('Bot実行中です');
});

const PORT = process.env.PORT || 8080;
server.listen(PORT, () => {
  console.log(`サーバーが起動しました PORT: ${PORT}`);
});
```
サーバーを立ててしまえばOK。

## tips2: アクセスがないと10分で落ちる
[公式ドキュメント](https://cloud.google.com/appengine/docs/legacy/standard/python/how-instances-are-managed)を見ると、

>アプリで自動スケーリングを使用する場合は、アイドル状態のインスタンスが約 15 分間非アクティブになり、シャットダウンが開始されます。1 つ以上のアイドル インスタンスを実行し続けるには、min_idle_instances の値を 1 以上に設定します。

とのことで、今回はコスパ重視でF1インスタンスを使っているのですがF1は「自動スケーリングのみ」という制約があるため、[cron.yaml](https://cloud.google.com/appengine/docs/standard/go/scheduling-jobs-with-cron-yaml?hl=ja)を使って定時httpリクエストを行います。

```yaml:crom.yaml
cron:
- url: /
  schedule: every 10 minutes
  retry_parameters:
    min_backoff_seconds: 5
```
こんな感じ。

## 自動スケーリングによる処理の重複
自動スケーリングによってインスタンスの入れ替わりが自動で行われる都合だと思いますが、たまに処理が重複します。
これについては実際のプログラム上で排他制御か同期を取って回避するのが良いと思います。

##
また追記していきます