34
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Discord BotAdvent Calendar 2020

Day 22

Discord Bot をシェルスクリプトだけで書く方法

Last updated at Posted at 2020-12-21

Discord Bot をつくるためのライブラリは,さまざまな言語で実装され,公開されています.わたしたちは,それらのライブラリを用いることで,簡単に Bot をつくることができます.

でも,それってつまらないのでは……?

そう思った私は,シェルスクリプトだけで Discord Bot を書きたいと思ってしまいました.

目標

今回は,以下の機能を持つ Bot の作成にチャレンジします:

  • 起動している間,常時メッセージを受け取る.
  • 「Ping」というメッセージを受け取ったら,同じチャンネルに「Pong」と投稿する.

レギュレーション

制限を設けなければ curl やら jq やらで簡単に作れてしまいます.そこで今回は, POSIX 準拠のコマンドで作ることにしました.とはいっても, POSIX 完全準拠の環境を用意するのは難しいので,あくまで「POSIX で定義されたコマンド名」という縛りにしました.

また, TLS の実装はさすがに厳しいものがあるので, OpenSSL だけは使ってもよいことにしました.機会があればここも自作したいなと思います.

環境

Ubuntu Server 20.04 LTS と GNU bash 5.0.17 で動作確認することを目標にします.

WebSocket

Discord Bot がリアルタイムにメッセージを受け取れるのは, Discord の API サーバと WebSocket で通信しているからです.これにより, HTTP の上で長時間のソケット通信を確立し,双方向的にデータを送信することができます.

Bot の作成には WebSocket の実装が不可欠です.そこで私は, WebSocket 部分をライブラリとして websocket.sh という名前で開発することにしました.
https://github.com/siketyan/websocket.sh

シェルはバイナリセーフではない

シェルは想像以上に高機能です.例えば, echo ">> $(uname -a) <<" のようにコマンドを実行した結果を利用することができます.また, echo "foobar" | tr [a-z] [A-Z] のように標準出力を別の標準入力にパイプでつなぐことができます.

しかし,この 2 つのうち前者はバイナリセーフではありません.コマンドはよく出力の最後に改行コードを出力して終了します(というよりも, POSIX では行は文字列と改行コードで構成されることになっています).しかしその結果を利用する側で改行コードや余計なスペースは必要ないことが多いです.したがって,シェルはこれらを適宜トリミングします:

$ echo ">>$(echo -e 'bar\n')<<"
>>bar<<

さらに,後者も場合によってはバイナリセーフではありません.シェルの機能としてはバイナリセーフなのですが,コマンドがバイナリセーフでないことが多いです.特に,文字列処理系のコマンドはそういったものが多いです.バイナリセーフなコマンドは dd ぐらいでしょう.

今回は, WebSocket というプロトコルを実装する特性上,バイナリセーフである必要があるので,内部的に 16 進数や 2 進数へ変換して処理することにしました.以下はコードの一部です:

wsi_getchar() {
  dd "bs=1" "count=1" 2>/dev/null
}

ws_read() {
  HEADER_1=$(wsi_getchar | wsi_ord | wsi_to_bin 8)
  HEADER_2=$(wsi_getchar | wsi_ord | wsi_to_bin 8)
  OPCODE=$(echo "$HEADER_1" | wsi_substr 5 8)
  LENGTH=$(echo "$HEADER_2" | wsi_substr 2 8 | wsi_to_dec)
  # ...
}

こうすることで,少なくとも ASCII の表示できる文字として有効な範囲に絞ることができるため,安全に扱うことができるようになりました.

HTTP

さて,早速 WebSocket の実装に入りましょう.しかし,一つ忘れてはいけないことがあります.それは, WebSocket は HTTP の上で動くプロトコルだということです.つまり, WebSocket を実装するには HTTP をも実装する必要があります.

とはいっても, HTTP は部分的に実装するぶんには簡単なプロトコルです.TCP あるいは TLS で確率したコネクションで以下のような内容を書き込めばリクエストを送ることができます:

GET / HTTP/1.1
Host: example.com

ただし,改行コードが CRLF であることと,最後に空行が必要であることに留意してください.

今回は WebSocket の仕様に関する詳細な説明は行いませんが,WebSocket での通信を確立するには, HTTP で Upgrade リクエストを送る必要があります.具体的な実装では以下のようになります:

ws_transceive() {
  WEBSOCKET_KEY="$(wsi_random_bytes 16 | wsi_base64_encode)"

  wsi_print "GET $2 HTTP/1.1"
  wsi_print "Host: $1"
  wsi_print "Upgrade: websocket"
  wsi_print "Connection: Upgrade"
  wsi_print "Sec-WebSocket-Key: $WEBSOCKET_KEY"
  wsi_print "Sec-WebSocket-Version: 13"
  wsi_print ""
  # ...
}

送信する内容は受信した内容で決定される

今回の要件では,「Ping」というメッセージを受け取ったタイミングで「Pong」というメッセージを送信しなければなりません.つまり,送信側 → OpenSSL → 受信側 → 送信側 → ... とループさせる必要があるのです.これは通常のパイプ機能で実現するのは極めて難しいです.

しかし,名前付きパイプという機能を使うとかなり容易に実現することができます.これは,ファイルシステム上に IO 用の特別なファイルを作成して,送信側はそこから読み取り,受信側はそこに書き込みます.

ws_create() {
  HANDLE="$(mktemp)"

  rm -f "$HANDLE"
  mkfifo "$HANDLE"
  echo "$HANDLE"
}

この関数は mktemp コマンドを使って一時ファイルのパスを取得したあと, mkfifo コマンドで名前付きパイプを作成します.前者のコマンドはファイルの生成もしてくれますが,パスを生成したいだけなので削除しています.

この機能を使うと以下のように実装できます:

ws_receive() {
  exec 3>"$1"
  # ...
}

ws_write() {
  echo "$1" >&3
}

構造が分かりづらいですが, ws_receive は関数名を受け取っておき,メッセージを受け取ったときにその関数を呼び出します.コールバックみたいな仕組みです.呼び出された関数は ws_write を使うことでメッセージの内容をもとに送信する内容を決定することができます.

挨拶をしよう

認証

Discord の Gateway API は,接続確立時に以下のような JSON を送信してきます:

{"t":null,"s":null,"op":10,"d":{"heartbeat_interval":41250,"_trace":["[\"gateway-prd-main-69kt\",{\"micros\":0.0}]"]}}

op は opcode を指し, 10 は Hello らしいです.
https://discord.com/developers/docs/topics/opcodes-and-status-codes#gateway-opcodes

挨拶をされたら,返さないといけません.また, Bot トークンを送信して認証する必要があります. DISCORD_TOKEN 環境変数が設定されているとすれば,以下のような実装で行えます:

get_json_number() {
    LC_ALL=C awk "match(\$0, /\"$1\":([0-9]+)/, g) { print g[1] }"
}

on_message() {
  JSON="$1"
  OP=$(echo $JSON | get_json_number "op")

  echo "< $JSON"

  if [ "$OP" -eq 10 ]; then
    ws_write $(printf '{"op":2,"d":{"token":"%s","properties":{"$os":"linux","$browser":"my_library","$device":"my_library"}}}' $DISCORD_TOKEN)
  fi
}

正規表現を用いて JSON を雑にパースしているのはぜひお許しいただければと思います…….

ハートビート

Gateway API との接続を確立した後,しばらく経つと自動的に切断されてしまいます.接続が使われているかを確認するために,ハートビートメッセージを定期的に送信しなければなりません.これは非同期で行う必要があるので,以下のようにして実行します:

heartbeat() {
    while true; do
        sleep $(expr $1 / 1000)
        echo "! HEARTBEAT" >&2
        ws_write '{"op":1,"d":null}'
    done
}

on_message() {
  # ...
 if [ "$OP" -eq 10 ]; then
    HEARTBEAT_INTERVAL=$(echo "$JSON" | get_json_number "heartbeat_interval")
    echo "< Hello message (Heartbeat Interval: $HEARTBEAT_INTERVAL)"
    heartbeat "$HEARTBEAT_INTERVAL" &
    HEARTBEAT_PID=$!
    echo "! Heartbeat started"
    # ...
  fi
}

先ほど紹介した Hello メッセージに heartbeat_interval という値が含まれているので,これに従います.単位はミリ秒です.

Ping, Pong!

メッセージの受信

ここまでくればおおよそお分かりかと思いますが,以下のようにしてメッセージを受信します.

get_json_string() {
    LC_ALL=C awk "match(\$0, /\"$1\":\"([^\"]+)\"/, g) { print g[1] }"
}

on_message {
  # ...
  if [ "$OP" -eq 0 ]; then
    EVENT_NAME=$(echo $JSON | get_json_string "t")
    echo "! Event dispatched: $EVENT_NAME"

    if [ "$EVENT_NAME" == "MESSAGE_CREATE" ]; then
      CHANNEL_ID=$(echo "$JSON" | get_json_string "channel_id")
      CONTENT=$(echo $JSON | get_json_string "content")
      echo "! Message created in $CHANNEL_ID: $CONTENT"
    fi

  elif [ "$OP" -eq 10 ]; then
    # ...
  fi
}

メッセージの送信

チャンネルにメッセージを投稿することは, Gateway API ではできません.したがって, REST API を用いる必要があります.そう, HTTP 通信です. HTTP の上で動く WebSocket を実装してきたので, HTTP を喋るのはもうお手の物でしょう.

print() {
    echo -ne "$1\r\n"
    log "> $1"
}

rest_api {
  print "POST /api/v6/$1 HTTP/1.1"
  print "Host: discord.com"
  print "Connection: close"
  print "Content-Length: $(echo -n "$2" | wc -c)"
  print "Content-Type: application/json"
  print "Authorization: Bot $DISCORD_TOKEN"
  print "User-Agent: Shell Script Only Bot (http://example.com, 0)"
  print ""

  echo -e "$2"
}

on_message {
  # ...
    # ...
    if [ "$EVENT_NAME" == "MESSAGE_CREATE" ]; then
      # ...
      if [ "$CONTENT" == "#!ping" ]; then
        echo "Sending message"
        rest_api "channels/$CHANNEL_ID/messages" '{"content":"pong","tts":false}' \
            | openssl s_client -verify_quiet -quiet -connect discord.com:443
        echo "Done"
      fi
    fi
  # ...
}

実際に実行してみると……

image.png

大成功です.無事に要件を達成することができました!

まとめ

シェルスクリプトの可能性を感じていただけたでしょうか.正直,私はシェル芸人と呼ばれる人ではありませんし,調べながら試行錯誤を繰り返してここまで実現しましたが,良い課題だったのではないかと思います.

よりよい実装方法をご存じの方がいれば,ぜひコメントで教えていただけますと幸いです.

最後に, websocket.sh を利用したコード全文を載せておきます.参考にしていただければ嬉しいです!

discord.sh
#!/bin/bash --posix

HOSTNAME="gateway.discord.gg"
PORT="443"
ENDPOINT="/?v=6&encoding=json"
ON_MESSAGE="on_message"
ON_CONNECT="on_connect"

. ./websocket.sh

get_json_number() {
    LC_ALL=C awk "match(\$0, /\"$1\":([0-9]+)/, g) { print g[1] }"
}

get_json_string() {
    LC_ALL=C awk "match(\$0, /\"$1\":\"([^\"]+)\"/, g) { print g[1] }"
}

heartbeat() {
    while true; do
        sleep $(expr $1 / 1000)
        echo "! HEARTBEAT" >&2
        ws_write '{"op":1,"d":null}'
    done
}

print() {
    echo -ne "$1\r\n"
    log "> $1"
}

rest_api() {
  print "POST /api/v6/$1 HTTP/1.1"
  print "Host: discord.com"
  print "Connection: close"
  print "Content-Length: $(echo -n "$2" | wc -c)"
  print "Content-Type: application/json"
  print "Authorization: Bot $DISCORD_TOKEN"
  print "User-Agent: Shell Script Only Bot (http://example.com, 0)"
  print ""

  echo -e "$2"
}

on_message() {
  JSON="$1"
  OP=$(echo $JSON | get_json_number "op")

  echo "< $JSON"

  if [ "$OP" -eq 0 ]; then
    EVENT_NAME=$(echo $JSON | get_json_string "t")
    echo "Event dispatched: $EVENT_NAME"

    if [ "$EVENT_NAME" == "MESSAGE_CREATE" ]; then
      CHANNEL_ID=$(echo "$JSON" | get_json_string "channel_id")
      CONTENT=$(echo $JSON | get_json_string "content")
      echo "Message created in $CHANNEL_ID: $CONTENT"
      
      if [ "$CONTENT" == "#!ping" ]; then
        echo "! Sending message"
        rest_api "channels/$CHANNEL_ID/messages" '{"content":"pong","tts":false}' \
            | openssl s_client -verify_quiet -quiet -connect discord.com:443
        echo "! Done"
      fi
    fi

  elif [ "$OP" -eq 10 ]; then
    HEARTBEAT_INTERVAL=$(echo "$JSON" | get_json_number "heartbeat_interval")
    echo "< Hello message (Heartbeat Interval: $HEARTBEAT_INTERVAL)"
    heartbeat "$HEARTBEAT_INTERVAL" &
    HEARTBEAT_PID=$!
    echo "! Heartbeat started"
    ws_write $(printf '{"op":2,"d":{"token":"%s","properties":{"$os":"linux","$browser":"my_library","$device":"my_library"}}}' $DISCORD_TOKEN)
  elif [ "$OP" -eq 11 ]; then
    echo "< Heartbeat"
  fi
}

on_connect() {
  echo "! Connected"
}

HANDLE="$(ws_create)"

trap 'ws_close' 2
ws_connect "$HANDLE" "$HOSTNAME" "$PORT" "$ENDPOINT" "$ON_MESSAGE" "$ON_CONNECT"
34
10
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
34
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?