マイクラのログインをdiscordに通知する【シェルスクリプト】

# はじめに
## 注意事項
素人の書いたコードです
ソースコードは、動作保証はありません
使った結果、何か問題が起きても責任は負えませんので、自己責任でお願いします

想定読者：`cd`,`ls`程度はわかる、`ssh接続`もわかる、`シェルスクリプト`もわかる

<hr>

**マインクラフトが動いているVPS上**でシェルスクリプトを実行します
そのため、**マインクラフトが動いているサーバーに乗り込む**必要があります

ssh接続で乗り込んでください
`conoha for game`などはssh接続するのに、セキュリティグループの設定とsshサーバーの起動が必要です
[ConoHa for Game（VPS）にSFTP接続する方法 - GameServer8 ゲームサーバーエイト](https://webst8.co.jp/gameserver/ftp-conoha-for-game/)

`conoha for game`の場合はブラウザからコンソール画面が開けるのでそれでもいいです
(操作性が微妙なのでおすすめはしない)

余談ですが、`conoha for game`と`conoha for VPS`は別物です。注意しましょう


## 動機
- マインクラフトのログイン通知をdiscordに出したい
- シェルスクリプト採用理由・方針
    - ゲーム用レンタルサーバーで動かす為、負荷を減らしたい
    - 環境整備などを避けたい
        - pythonのライブラリインストール作業・jqのインストール作業 など(深い理由はない)


# 本文
## 仕様
 - ログインとログアウトをdiscordにメッセージを送信
 - ログインは通知ありで送信。ログアウトはsilentメッセージで送信
 - 深夜の特定時間は止める(うるさいから)
 - discordのメッセージは毎日朝に全て削除される 

`silentメッセージ`:通知を出さずにメッセージを送れる。ディスコードの標準機能。LINEにもあるやつ

## 構成

**マインクラフトが動いているVPS上**でシェルスクリプトを実行
- `crontab`によって`check_in_out.sh`を毎分実行
    - `setting/player_list.txt`のプレイヤーIDとログイン状態を読み取る
    - プレイヤーのログインログアウト状態に変更があった場合`discord`に送信
    - 送信時、`setting/player_list.txt`のプレイヤーIDとログイン状態を更新
    - 送信時、メッセージIDを`setting/message_ids.txt`に保存
- `crontab`によって`delete_mes.sh `を毎日朝9時に実行
    - `setting/message_ids.txt`のメッセージIDを読み取り、メッセージ削除

メッセージの送信・削除には`curl`と`webhook`を利用

## ファイルで各自、書き換える場所
基本的に以下のスクリプトはコピペで動きますが、書き換えるべき場所がいくつかあるので記載します

`config.sh`：全般
`check_in_out.sh`：`source "/root/world_observer/setting/config.sh"`のパス
`delete_mes.sh`：`source "/root/world_observer/setting/config.sh"`のパス
`player_list.txt`：ID(IDはマイクラログイン時に頭の上に出るやつ、記載したIDに対してログイン、アウトが監視されるようになる)

`crontab`で動かす関係上、全体的にプルパスで記載することを推奨
実行タイミングを変えたい場合は`init.sh`や`crontab -e`で編集をしてください

webhookがよくわからない人はこの記事一番後ろに乗せてるURLの最初のやつみましょう


## ファイル構成
```sh
~/world_observer
    |--check_in_out.sh              # ログインログアウトを監視・通知の送信
    |--delete_mes.sh                # メッセージの削除
    |--init.sh                      # 初期設定(一度のみ実行)
    |--setting/                     # 設定ファイルなどを保存するディレクトリ
       |--config.sh                 # パスの設定用ファイル
       |--message_ids.txt           # 削除用のメッセージIDを保存するtxt
       |--player_list.txt           # プレイヤーIDとログイン状態を保存するtxt

```

`config.sh`：パスの設定用ファイル
```bash:config.sh
#!/bin/bash
#################################################

# webhook：通知したいサーバーに応じて変更する
WEBHOOK_URL="https://discord.com/api/webhooks/0000/AAAABBBBCCCCZZZZ"

# このスクリプトを配置した場所
pwd_path="/root/world_observer"
# logファイルの場所(マインクラフトサーバーの動いている配下にある)
logfile="/opt/minecraft_server/logs/latest.log"

# 書き換え不要
status_file="$pwd_path/setting/player_list.txt"
message_ids_file="$pwd_path/setting/message_ids.txt"

#################################################

```

`check_in_out.sh`：ログインログアウトを監視・通知の送信
```bash:check_in_out.sh
#!/bin/bash
#################################################
source "/root/world_observer/setting/config.sh"
#################################################

# メッセージを送信する関数 引数：ユーザーID,退出フラグ,通知フラグ
sent_message_discord (){
    # ユーザー名に含まれる[_]をエスケープ\
    name_mes=$(echo "${1}" | sed 's/_/\\\\_/g')
    in_out=${2}
    notification_flag=${3}
    main_mess="${name_mes}    -    [ ${in_out} ]"

    # 通知するか否か
    if "$notification_flag"; then
        REQUEST_BODY="{\"content\":\"$main_mess\"}"
    else
        REQUEST_BODY="{\"content\":\"$main_mess\",\"flags\": 4096}"
    fi
    # メッセージの送信
    RESPONSE=$(curl -s -X POST -H "Content-Type: application/json" -d "$REQUEST_BODY" "$WEBHOOK_URL?wait=true")

    # curl結果からメッセージIDをを取り出す
    MESSAGE_ID=$(echo "$RESPONSE" | grep -o ',"id":"[0-9]*"' | sed -E 's/,"id":"([0-9]*)"/\1/')
    # メッセージIDをtxtに保存する
    if [[ -n "$MESSAGE_ID" ]]; then
        echo "$MESSAGE_ID" >>$message_ids_file
    fi
}

main () {

    player_num=0
    update_flag=false
    # テキストファイルからプレイヤーID、ログイン状態、プレイヤーの人数を読み取る
    if [[ -f "$status_file" ]]; then
        while IFS=' ' read -r key value; do
            player_name[${player_num}]=$key
            player_stat[${player_num}]=$value
            ((player_num++))
        done < "$status_file"
    fi
    # プレイヤーの数だけ実行
    for ((i = 0; i < player_num; i++)); do
        # ログには下のような記載がある。ログの中から最後の left か joined を変数に保存
        # [02:33:09] [Server thread/INFO]: Player_ID_A joined the game
        in_out_st=$(tail -n 100 "$logfile" | grep -E "${player_name[i]} (left|joined) the game" | awk 'END {print $5}')

        # プレイヤーがINしている状態で ワールドからleftした時
        if [[ "$in_out_st" == "left" && "${player_stat[i]}" == "IN" ]]; then
            # メッセージの送信関数呼び出し,ログイン状態の更新,アップデートフラグをtrue
            sent_message_discord ${player_name[i]} "OUT" false
            player_stat[i]="OUT"
            update_flag=true
        # プレイヤーがOUTしている状態で ワールドからjoinedした時
        elif [[ "$in_out_st" == "joined"  && "${player_stat[i]}" == "OUT"  ]]; then
            # メッセージの送信関数呼び出し,ログイン状態の更新,アップデートフラグをtrue
            sent_message_discord ${player_name[i]} "IN" true
            player_stat[i]="IN"
            update_flag=true
        fi
    done
    # ログイン状態の更新が行われている時、テキストファイルを更新する
    if "$update_flag"; then
        for ((i = 0; i < player_num; i++)); do
            echo "${player_name[i]} ${player_stat[i]}">>./tmp.tmp
        done
        mv ./tmp.tmp "$status_file"
    fi
}

main
```
`delete_mes.sh`：メッセージの削除
```bash:delete_mes.sh
#!/bin/bash
#################################################
source "/root/world_observer/setting/config.sh"
#################################################

# ファイルがある時、ファイルに保存してあるメッセージIDのメッセージを削除してファイルを空にする
if [[ -f "$message_ids_file" ]]; then
    while read -r MESSAGE_ID; do
        curl -X DELETE "$WEBHOOK_URL/messages/$MESSAGE_ID"
    done < "$message_ids_file"
    rm -f "$message_ids_file"
    touch "$message_ids_file"
fi
```
`init.sh`：初期設定(一度のみ実行)
間違えて2回以上実行した場合は、多重に実行されてしまうため、`crontab -e`で内容を編集してください
```bash:init.sh
#!/bin/bash
#################################################
source "/root/world_observer/setting/config.sh"
#################################################

file1=check_in_out.sh 
file2=delete_mes.sh 
chmod 755 $file1
chmod 755 $file2 

# cronを使って定期実行を設定 crontab -e で手動で操作してもいい
# 分  時  日  月  曜日  コマンド
# 0時と9時から23時の1分ごとに監視 (1:00から8:59は実行しない)
(echo "* 0,9-23 * * * $pwd_path/$file1"; crontab -l) | crontab -
# 9時1分に削除コマンドを走らせる
(echo "1 9 * * * $pwd_path/$file2"; crontab -l) | crontab -

echo "----------------------------------------------"
echo "Don't run init.sh ※※ twice ※※"
echo "You can check and edit[crontab -e]"
echo "----------------------------------------------"

```

`player_list.txt`プレイヤーIDとログイン状態を保存するtxt
(当たり前だが、Player_A_IDは書き換える)
```txt:player_list.txt
Player_A_ID OUT
Player_B_ID OUT
```
`message_ids.txt`：削除用のメッセージIDを保存するtxt(空ファイル)
```txt:message_ids.txt

```

# おわりに
## 感想
以下は雑に書いた感想なので読ませる文章ではないですが、参考までに乗せておきます

 - `player_list.txt`は普通にcsvにした方が良さそうですよね、後から思いました
 - いろいろやってたら思ったよりファイルが増えちゃった...
 - `AutoDelete`というメッセージ自動削除のdiscord botがあるけど、今は動かないらしい
 - 多分pythonかdiscord.pyでやった方が素直
 - 23時にログインして、2時にログアウトすると朝の9時にログアウト通知が来るのは気持ち悪い(深夜にログアウトするとログは残るが、ステータスの変更スクリプトが走っていないため)
     - 9時1分にメッセージは消えるし、通知もサイレントなので運用的に困ることはないはず
         - 最初はシェルスクリプトの中で現在時刻を取得し、分岐を組んでたが、途中からcrontabに変更した弊害
 - マイクラのIDにアンダースコア(_)で囲まれている部分があると、discord上で斜体になってしまう。それを回避するためにこのソースコードではエスケープを施している、ただ、jsonの中でもエスケープとして機能してしまうため二重にエスケープしてる
     - ↑`sed 's/_/\\\\_/g'`の部分のこと　`_`を`\\\\_`に置換
     - アスタリスク(*)なんかでも同様のことが起きると思うが、IDにアスタリスクは珍しいと思うのでやってない
         - IDにアンダースコアが一つのみしか含まれてない時にエスケープしても大丈夫なはず。今discordで試した。でもスクリプト上だともしかしたらバグるかも
 - ログファイルをtailで読み取ってるのはログファイルが長い時に処理が重くなるのを警戒してですが、マイクラのログファイルってあんま長くならないし、定期的にアーカイブされて別ファイルになるから杞憂
 - メッセージを消してるのに深い理由はないのですが、あまり誰がどれだけログインしたかが、後から辿れない方がいいかなと思ったためです(毎日消えるのが逆に嫌ならcrontabを月一とかで動かしてもいいし、ソースを改変して、メッセージIDの取得と書き出し、メッセージ削除の部分を消せばいいと思います)
 - あと`player_list.txt`のファイル末尾に改行がないとエラー起きるので注意です

```bash:ファイル改行のイメージ(ファイル末尾に改行が入っているかどうかの例です)
# NG：改行されてない
root@vm-xxxxxx:~# echo -n aaa > xxx
root@vm-xxxxxx:~# cat xxx 
aaaroot@vm-xxxxxx:~# echo aaa > yyy

# OK：改行されてる
root@vm-xxxxxx:~# cat yyy 
aaa
root@vm-xxxxxx:~# 
```


## 参考URL
[外部サービスからDiscordにメッセージを送る（Webhook)](https://zenn.dev/lambta/articles/5edbda4ccb1ec6)

[DiscordのWebhookでSilentMessageを送信する方法 | こたのお考え](https://yashikota.com/blog/discord-webhook-silent-message/)

[Discord APIの新しいWebhookエンドポイント(EditとDelete) #discord - Qiita](https://qiita.com/tignear/items/18f5384fa13f3c0dc6cf)