Discord で、seshのようなパーティ募集と、募集のリスト表示を作りたかった話

## Discordでのパーティ募集をいい感じにしたい
下の図のように、Discordでゲームのコンテンツへの参加募集をしていましたが、
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3679605/cab982cb-baac-fe8a-99bb-88d19f327408.png)
* 自分の投稿に絵文字でリアクションをしておいて、参加者を募るのですが、リアクションがされてないとなかなかリアクションを新たに押すのはめんどくさい。募集告知に反応して自動でリアクションが設定できないか。
* 募集告知をしていても、会話でタイムラインが流れると、少し前の募集告知は流れて行ってしまい、募集が目につかない。今募集されている告知をリスト表示できないか。

というような内容をなんとかしたいなーと思ってました。
最初に目を付けたのが、Discordの公開してあるBOTで、seshというものです。

https://sesh.fyi/

いいなと思った点は、次のようなポイントです。
* BOTコマンド`/create`でテンプレートに従って入力すると、日時や内容を設定して投稿できる。
* Embedのボタンで参加の意思表示や、タンクなど参加ロールの申請などができる。
* `/list`で、seshにて投稿された募集告知が、リストになって再投稿される。

開催前にリマインダーとしてDM送信される、など他にも機能はありますが、欲しかったのは上記のような内容です。
良さそうだったので、お試しで導入してみたのですが、次の点がDiscordサーバの運用と合わず、早々にやめてしまいました。
* パーティの野良募集の延長のようなDiscordサーバなので、ログインしてたら参加する、というような、ふんわりした参加意思表示もしたかったが、ボタンをカスタマイズするにはseshの課金が必要
* タンクなどの参加ロールもいいが、実際には中で自由にロールを調整するので、そこまで重要ではない
* リマインダーでDM送信されてくるのが鬱陶しい（これは設定でなんとかなりそうですが）

## 必要最低限のseshみたいなのを作っちゃおう
それまでに実装していた自動リアクション付加機能に、タイムラインが流れた後でもリスト表示ができたら十分だったので、がちゃがちゃっと作りました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3679605/3b1fc284-d7c4-dc5b-be7c-b9571d4f3e36.png)

* 投稿メッセージの中に、`！募集告知！`という文字列があれば、参加します！などの絵文字でリアクションをBOTが付加しています。
* `!blist`というコマンドで、BOTが過去10日間の`！募集告知！`の投稿をリスト表示してくれます。
（本当は、まだ募集中の未来の開催予定日の募集、としたかったのですが、自由な投稿形式なので諦めました）
* Embedの文字をクリックすると、投稿当時のメッセージへジャンプする
* 元の募集投稿を削除すると、`!blist`実行時にもリストから消えてほしい
（これは微妙に実現できていませんが、諦めました）

## Google Cloud Datastoreの利用
当初は別記事にもしたファイル管理での実装をしていたのですが、

https://qiita.com/calcMCalcm/items/7139f9b9902efaca76dd

レコードの全消し・全追加ならファイル管理が楽だったのですが、今回の募集告知のリストでは、投稿や削除毎にレコードを追加削除する必要があり、ファイルでは微妙にめんどくさいことに気づきました。~~（実装前に気づけ~~
あまり無いとは思いますが、データのメンテナンスも考えると、簡単なDBがいいだろうと思いました。
運用しているBOTは、Google App Engine（GAE）で稼働していたので、GAEと相性が良さそうなDatastoreを利用してみることにしました。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3679605/1e7fd8c6-31ea-903c-5852-39b2fe7ab9e3.png)
素人の見るに堪えないコードですが、そっと晒しておきます。

```python:discordbot.py
import discord
from discord.ext import commands, tasks
#（省略）
import os
import pandas as pd
#（省略）
import datetime
#（省略）
from google.cloud import datastore

intents = discord.Intents.default()
intents.message_content = True
bot = commands.Bot(command_prefix='!', intents=intents)

client = datastore.Client()

#（省略）
# datastoreの古いデータ削除
@tasks.loop(hours=24)  # 24時間ごとに実行
async def delete_datastore():
    dt_now1=datetime.datetime.utcnow() + datetime.timedelta(hours=DIFF_JST_FROM_UTC) - datetime.timedelta(days=20)
    dt_now=dt_now1.strftime('%Y/%m/%d %H:%M:%S')

    try:
        query = client.query(kind="boshu")
        query.keys_only()
        query.add_filter("dt_now", "<", dt_now)
        dell_ids = list([entity.id for entity in query.fetch()])
        for row in range(len(dell_ids)):
            key = client.key("boshu", dell_ids[row])
            client.delete(key)

    except Exception as e:
        print(f'エラーが発生しました: {e}')

# Botがメッセージ削除を受信したときに実行されるイベント
@bot.event
async def on_message_delete(message):

    if message.channel.id in (CHANNEL_SUNABA,CHANNEL_BOSHU):
        try:
            query = client.query(kind="boshu")
            query.add_filter("id", "=", message.id)
            query.keys_only()
            dell_id = list([entity.id for entity in query.fetch(limit=1)])
            key = client.key("boshu", dell_id[0])
            client.delete(key)

        except Exception as e:
            print(f'エラーが発生しました: {e}')

# Botが準備完了したときに実行されるイベント
@bot.event
async def on_ready():
    print("bot is ready")
#（省略）
    delete_datastore.start()  # タスクを開始
    
# チャンネルの募集告知リスト表示コマンド !blist
@bot.command(description="募集告知の過去10日間分をリスト表示します。今日のおでかけ、すなばで有効です",brief="募集告知のリスト表示")
async def blist(ctx):
    # チャンネルIDとユーザIDをチェック
    dt_now1=datetime.datetime.utcnow() + datetime.timedelta(hours=DIFF_JST_FROM_UTC) - datetime.timedelta(days=9)
    dt_now=dt_now1.strftime('%Y/%m/%d %H:%M:%S')
    if ctx.channel.id in (CHANNEL_BOSHU,CHANNEL_SUNABA):
        await ctx.channel.send("過去10日間の募集告知は・・・")
        query = client.query(kind="boshu")
        query.add_filter("channel", "=", ctx.channel.name)
        query.add_filter("dt_now", ">", dt_now)
        query.order=["dt_now"]
        df1=list(query.fetch())
        df=(pd.json_normalize(df1))

        if df.empty == False:
            for row in df.to_dict(orient="records"):
                embed = discord.Embed(title=row['content'],url=row['url'])
                embed.set_author(name=row['author'], icon_url=row['author_icon'])
                embed.set_footer(text=row['dt_now'])
                await ctx.channel.send(embed=embed)
        else:
            await ctx.channel.send("ちょっと見つかりませんでしたー")

# Botがメッセージを受信したときに実行されるイベント
@bot.event
async def on_message(message):
#（省略）
    # メッセージが募集チャンネルで送信された場合
    if message.channel.id in (CHANNEL_BOSHU,CHANNEL_SUNABA):
        #メッセージ送信者がBotだった場合は無視する
        if message.author.bot:
            return
        else:
            if "！募集告知！" in (message.content):
                # リアクションのリストを取得
                reactions = message.reactions
                # リアクションがない場合
                if len(reactions) == 0:
                    # リアクションを付ける処理
                    await message.add_reaction("<:ishi_sanka:1106768022582079498>")
                    await message.add_reaction("<:ishi_sanka_if_login:1196356878926626927>")
                    await message.add_reaction("<:ishi_if_not_enough:1213981302412550204>")
                    await message.add_reaction("<:ishi_ouen:1196043584156209201>")
                    await message.add_reaction("👍")
                # リアクションがある場合
                else:
                # リアクションの種類とユーザをチェック
                    for reaction in reactions:
                        if reaction.emoji == "<:ishi_sanka:1106768022582079498>": # リアクションの種類が一致する場合
                            users = await reaction.users().flatten() # リアクションしたユーザのリストを取得
                            if bot.user not in users: # BOT自身がリアクションしたユーザに含まれない場合                    # リアクションとして絵文字を送信する
                                await message.add_reaction("<:ishi_sanka:1106768022582079498>")
                                await message.add_reaction("<:ishi_sanka_if_login:1196356878926626927>")
                                await message.add_reaction("<:ishi_if_not_enough:1213981302412550204>")
                                await message.add_reaction("<:ishi_ouen:1196043584156209201>")
                                await message.add_reaction("👍")

                dt_now1 = datetime.datetime.utcnow() + datetime.timedelta(hours=DIFF_JST_FROM_UTC)
                dt_now = dt_now1.strftime('%Y/%m/%d %H:%M:%S')
                if len(message.content)>50:
                    content=' '.join(message.content.splitlines())[:50]+'...'
                else:
                    content=' '.join(message.content.splitlines())

                with client.transaction():
                    incomplete_key = client.key("boshu")

                    task = datastore.Entity(key=incomplete_key, exclude_from_indexes=('author','author_icon','url','content'))

                    task.update(
                        {
                        'id':message.id,
                        'author':message.author.display_name,
                        'author_icon':message.author.display_avatar.url,
                        'dt_now':dt_now,
                        'channel':message.channel.name,
                        'url':message.jump_url,
                        'content':content,
                        }
                    )

                    client.put(task)

    await bot.process_commands(message)
```

## 製作中に、工夫したり妥協したり諦めたりしたこと
ずっと言い訳のターン
#### 複数インスタンス時の対応
GAEでは、そろそろ環境が限界です環境作り直します、と~~勝手に~~別のインスタンスを立ち上げられるので、環境（BOT）が2つあるタイミングがあったりします。
その時に投稿があると、2つとも反応してしまうので、せめてリアクションは1回にしようと、かっこ悪いですがチェックをしています。
#### 募集告知投稿時、Datastoreに登録する
新規投稿時に新規登録するだけで、変更にはそもそも対応するつもりはありませんでした~~めんどくさいので~~。
できるだけ投稿時のニュアンスがわかるように、メッセージの情報を取得し、Datastoreに登録しています。
投稿の削除対応や、日付とチャンネルでの抽出をするつもりだったので、メッセージID、日時、チャンネル以外は、index作成除外としています。
データを登録し、しばらくしてから気づいたのですが、日時の``dt_now``は日付時間ではなく文字列型として登録されていました。当初は一生懸命、書式をDatastoreに合わせようとしていたのですが、文字列で登録されていたのでもう文字列でいいやと、あきらめました。
日時とチャンネルは、同時に検索条件になるので、複合インデックスとしています。
```ruby:index.yaml
indexes:
- kind: boshu
  properties:
  - name: channel
  - name: dt_now
    direction: asc
```
#### 募集投稿のリスト表示
Datastoreからデータを抽出し、EmbedでDiscordに投稿しています。
Datastoreでは、日付時間のような単純増加の項目にindexを指示すると問題がある、というような記載も見ましたが、~~めんどくさいので~~小規模だからいいだろうと諦めています。
#### 募集投稿が削除された時にDatastoreからレコードを消す
きれいには実現できませんでした。
``on_message_delete``というメッセージ削除時のイベントがあるのですが、これはBOTがキャッシュで覚えているメッセージしか機能しないもので、具体的にはBOTが起動して以降の投稿について削除された場合のみ、イベントとして発動します。
GAEは、先にも述べたように、定期的にインスタンスが変わるので、毎回BOTのキャッシュはリセットされ、過去を振り返ることが苦手な子として転生しつづけることが運命づけられています。
下記の記事に、``on_raw_reaction_add``でキャッシュになくても対応する例が記載されており、当方でもdeleteで真似てみたのですが、なんだかエラーになってしまったので早々に諦めました。

https://qiita.com/Pumila/items/cf447c24538e13994a38

#### Datastoreで古いデータを削除する
登録しっぱなしでもたぶん容量的には問題ないのですが、あとで綺麗にするのも大変なので、定期的に古いデータは消しておこうと思っていました。
BOTのコードではなく、Google Cloud Function＋Cloud scheduler＋cloud pub/sub で実現しようと試行錯誤したのですが、Cloud FunctionからDatastoreへのアクセス権をうまくクリアすることができず、~~めんどくさかったので~~BOTのTaskで書いてしまっています。Discordに対して何もしないのに、どうなのかとは思いますが・・・。
繰り返しになりますが、GAEはインスタンスの切り替えがあるので、Cloud schedulerで週に一度実行というような形にしたかったのですが、インスタンス切り替えが起こった毎に実行されても別にいいか、と妥協もしています。

## 感想とか
Google Cloudの300$の有効期限が2024/03/16までだったので、ちょっと焦っていたというのもあります。
最近流行りの生成AIなどではなく、枯れた機能ばかり使っていたとは言え、素人の私が~~ローカルでのテストを放棄して~~クラウドで試行錯誤しながらがちゃがちゃとコーディングしてこの程度しか費用が発生していない（しかも無償クレジットで無料）というのはすごいですね。
今後はできるだけ無料枠の中で運用できるようにしていけたらと思っています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3679605/1bbe3822-16b7-59b5-de5c-46e51334bd27.png)

## 参考にさせていただいた記事

https://qiita.com/butada/items/890fda1aa4ef22cc3623

https://qiita.com/Mahito/items/7578fe7b7a276a597784

https://qiita.com/tfuruya/items/fa529c6cf54adaa81aa7

https://qiita.com/sasayabaku/items/c336ad89c37782e3e7d5

https://qiita.com/halglobe0108/items/d6794e2104b6c90580a6