Delphi で Discord Bot を作る

Delphi で Discord Bot のフレームワーク作ったった

# 結論を急ぎたい人は↓へ

https://bitbucket.org/freeonterminate/discordbot/

# Discord ボットへの道

ただ、Discord ボットが作りたかっただけなのに [WebSocket を作る羽目になった](https://qiita.com/pik/items/cebd44b7320fd8cb0484)僕たちの明日はどっちだ！
こっちだ！！

## Discord API

まずは、この図を見てください。

![disco.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12977/0d6a945f-9cd7-e8bb-0a32-4c5de8411999.png)

Discord API はいくつかに分かれているのですが、基本的には以下の二つを使います。

* Gateway API
* HTTP API

### Gateway API
WebSocket を使って Discord がデータを送ってくる API です。
WebSocket で接続されると、まず Discord から [Hello Payload](https://discordapp.com/developers/docs/topics/gateway#hello) が送られてきます。

#### Payload
Discord との通信には [Payload と呼ばれる JSON](https://discordapp.com/developers/docs/topics/gateway#payloads) が用いられます。
例えば↓は HeartBeat に対する応答です。

```json
{"t":null,"s":null,"op":11,"d":null}
```

Payload には以下の要素があります。

* op
[Operation Code](https://discordapp.com/developers/docs/topics/opcodes-and-status-codes#gateway-opcodes)

* d
Object

* s
Sequence Id

* t
Event Type

ただし、s, t に関しては op が 0 (Dispatch) だった時のみ値が存在します。
基本的に Discord Gateway API の文書が判りづらいのは、Payload JSON が省略されて d タグ内の事象について書いてあるからです。
たとえば、Hello Payload の場合

```json
{"t":null,"s":null,"op":10,"d":{"heartbeat_interval":41250,"_trace":["discord-gateway-prd-xxx"]}}
```

という値が返ってきますが、Discord の方では

```json
{
  "heartbeat_interval": 45000,
  "_trace": ["discord-gateway-prd-1-99"]
}
```

としか書いていないため d の内容だとは判りづらいのです。
これに気づくまでにもかなり掛かりました。

#### Gateway API で重要な Operation 

押えておくべき Op についてです。
`名前(OpCode)` として記載します。

まずは受信部です。
##### 受信部

* **Hello (10)**
WebSocket 接続後、最初に送られてくる応答です。
これを受信したら Identify を送り、Heartbeat を開始します。

* **Heartbeat ACK (11)**
Heartbeat を受信したことを教えてくれます。
これが返ってこない場合は Heartbeat を送り直す必要がありますが、返ってこない時は、基本的に Discord サーバが落ちている or 切断されたときなので、今回は処理を省略しました。

* **Dispatch (0)**
これが一番重要なコードです、というか、ここが本作業するところです。
t タグには文字列で例えば

```
READY
TYPING_START
USER_UPDATE
```

などが入っています。
これがイベントの名前となるので、このイベント毎に必要な処理をします。
ここで重要なのは、以下の２つです。

* READY
準備完了を示します。
d には session_id と user.username で自身の名前が入っています。
必要であれば保存しておきます。

* MESSAGE_CREATE
Discord で誰かがメッセージを送信したことを示します。
d タグには mention や user名、メッセージの内容等が入っているので、それらを取得して何らかの処理をします。
READY で取得した UserName が Mention の名前と一緒であれば自分（bot）に対しての発言だと判ります。

##### 送信部

* **Heartbeat(1)**
Hello で受け取った `heartbeat_interval` に指定されたミリ秒ごとに Heartbeat を送信します。

* **Identify(2)**
自分の情報を送ります。
例えば、Bot Token 等です。
今回作ったライブラリでは、こんな感じで設定しています。

```pascal
  D.token := FToken;
  D.properties.os := GetOSName;
  D.properties.browser := 'pk_net_discord_bot';
  D.properties.device := 'delphi';
  D.compress := False;
  D.large_threshold := 250;
  SetLength(D.shard, 2);
  D.shard[0] := 0;
  D.shard[1] := 1;
  D.presence.status := 'online';
  D.presence.afk := False;
```


特に Token は、この後 HTTP API の方でも使うので重要です。
Token はボットを登録したときに Discord から発行される文字列です。
Discord でボットを登録する処理は色々な記事があるので、例えば [DiscordのBot登録・設定・トークンの発行方法](https://dot-blog.jp/news/discord-bot-token/) などの記事をご覧ください。

### HTTP API
HTTP API を通じて、Discord にチャットメッセージを送ります。
今回作ったライブラリはあえてシンプルにした物なので、HTTP API では、発言するものしか使っていません。
HTTP API は機能によって送信先が変わります。
例えば、メッセージを送信する URL は以下のようになります。

```
https://discordapp.com/api/v6/channels/%s/messages
```

`%s` には、チャンネルの ID が入ります。


#### ヘッダ
HTTP API で、重要なのは HTTP ヘッダに指定する

* UserAgent
* Authorization

です。

##### UserAgent
UserAgent は何でも良いのですが必ず有効な UserAgent が指定されていなければなりません。
ライブラリでは下記の文字列を指定しています。
%d にはバージョンが入ります。

```text
UserAgent=DiscordBot (PK.Net.Discord.Bot, %d)
```

##### Authorization
Authorization には Token を指定します。

```text
Authorization=Bot TOKEN
```

とします(`TOKEN` の部分は Bot の Token に置き換えます)。
これはこのライブラリ特有の書き方では無く、必ずこのように指定しなければいけません。

#### Payload
次に Payload JSON を作成します。
d タグの中身は送信するメッセージによって変わります。
例えば、テキストを送信する場合の d タグは

```json
{"content":"メッセージ内容", "tts":false, "embed":null}
```

となります。
このライブラリでは更に画像といった APPLICATION_OCTET_STREAM も送れるようになっています。

Payload JSON やストリームデータは、最終的に Multipart FormData として Post します。
これらを処理しているコードが↓です。

```pascal:実際にパッキングして送信するメソッド
procedure TDicordTalkBot.SendRaw(
  const iCMDURL, iPayloadJsonStr: String;
  const iStream: TStream;
  const iStreamName: String);
begin
  FHttpClient.UserAgent := Format(HEADER_USER_AGENT_VALUE, [SELF_VERSION]);

  var Header: TNetHeaders;
  SetLength(Header, 1);
  Header[0].Name := HEADER_AUTHORIZATION;
  Header[0].Value := Format(HEADER_AUTHORIZATION_VALUE, [FToken]);

  var FormData := TMultipartFormData.Create;
  try
    FormData.AddField('payload_json', iPayloadJsonStr);

    if iStream <> nil then
    begin
      FormData.AddStream(
        'file',
        iStream,
        iStreamName,
        ContentTypeToString(TRESTContentType.ctAPPLICATION_OCTET_STREAM));
    end;

    var Path := iCMDURL.Trim;
    if Path.StartsWith('/') then
      Path := Path.Substring(1);

    var Res := FHttpClient.Post(DISCORD_HTTP_URL + Path, FormData, nil, Header);

    if Res.StatusCode > 399 then
      CallOnError(ERR_CODE_SEND_FAILED, Res.ContentAsString(TEncoding.UTF8));
  finally
    FormData.DisposeOf;
  end;
end;
```

## 上手くいくと思った？ざんねーん！さやかちゃんでした！
本来なら、

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/12977/b8aeb753-77f0-d88f-c6cf-30858066d414.png)

こんな感じで処理が完了するのですが…
一番最初の図にわざわざ **Claud Flare**と書いた通り、Claud Flare が勝手に接続を切ってきます。
そのため、適宜繋ぎ直してやるのですが、その際に Resume(6) という Op コードを送って再接続処理を申請します。本来は。
ただ、これが必ず上手く行くわけでは無い事が経験的に判ったので、このライブラリでは、再接続処理をせずに、最初から接続をやり直しています。

# ライブラリの使い方
基本的にコマンドラインアプリで使われることを想定しています。
そのため TApplication に代わる TBotApplication が定義されています。

TBotAppilcation を用いると Discord Bot が簡単に作れます。
具体的には以下のような感じです。

```pascal
 program HelloBot;
 
 {$APPTYPE CONSOLE}
 
 uses
   PK.Net.Discord.Bot;
 
 var
   FBot: TBotApplication;
 begin
   FBot := TBotApplication.Create(TOKEN);
   try
     // エラー発生で呼ばれます
     // 本当はここで何か処理をすべきです。
     FBot.ErrorHandler :=
       procedure (const iErrCode: Integer; const iMessage: String)
       begin
         Writeln(Format('ERROR (%d): %s', [iErrCode, iMessage]));
       end;

     // Discord と接続したときに呼ばれます 
     FBot.ReadyHandler :=
       procedure
       begin
         Writeln('Connected.');
       end;
 
     // 自分宛にメッセージが来たとき呼ばれます。
     FBot.MentionHandler :=
       procedure (
         const iChannelID, iFromUserID, iFromUserName, iMessage: String)
       begin
         // iChannelID:    メッセージを受け取ったチャンネルです
         // iFromUserID:   メッセージを送ってきたユーザーの ID です
         // iFromUserName: メッセージを送ってきたユーザーの名前です
         // iMessage:      メッセージ内容です
         Writeln(
           Format(
             '#%s: %s(%s) say "%s"', 
             [iChannelID, iFromUserName, iFromUserID, iMessage])
         );

         if iMessage = 'bye' then
           FBot.Stop; // これで Run ループを抜けます

         // メッセージの送信はこれだけで、非常に簡単です。
         FBot.Send(iChannelID, 'Hello, Discord!');
       end;

     // Run ループです。 
     FBot.Run;
   finally
     FBot.DisposeOf;
   end;
end.
```

このほかにも

```pascal
TBotApplication.DispatchHandler
```

を使えば Dispatch で送られた全てのイベントを処理できます。

```pascal
TBotApplication.MessageHandler
```

を使えば自分宛以外のメッセージも処理できます。

# まとめ
車輪を２つ作るのは流石に大変だったよ。