discord bot(python + replit) 個人的な修正点&コピペまとめ

[replit](https://replit.com)でdiscordの身内用botを何度か作り直したときの副産物である、コードの修正や個人的に使いやすかったコピペをまとめておく。
replitでほぼ24時間動くdiscord botを作る方法は[こちら](https://qiita.com/eureyuri/items/c5f041773c93a54b9f92)を参照。ただし、`discord.py`の更新により、そのままでは実行できない。

実際に自分が作ったコードは[ここ](https://replit.com/@Attomake/AtmkBot24h)から確認できる。ただし、**身内ノリの関係でう〇こが大量に出てくるので注意。**

```diff_python
- #赤で表示されている部分を消す
+ #緑で表示されている部分を追加する
```

# 修正
## intentsの適用
```diff_python
- client=discord.Client()
+ intents=discord.Intents.default()
+ intents.message_content=True
+ client=discord.Client(intents=intents)
```
いつぞやの`discord.py`の更新で、そのままだと`TypeError : __init__() missing 1 required keyword_only argument : 'intents'`が出てくるが、`client=discord.Client(intents=discord.Intents.default())`と直しても(少なくともreplit上で起動した場合は)discord上の発言を読み取れない。

## トークンの隠蔽
```diff_python
- TOKEN = os.getenv("TOKEN")
- client.run(TOKEN)
+ client.run(os.getenv('TOKEN'))
```
bot内に**管理者以外**が使える`eval`や`exec`がある場合、TOKENを定義してしまうと`globals()`を実行するだけで簡単にTOKENが盗まれる。

## 再起動スムーズ化
```diff_python
- keep_alive()
- client.run(os.getenv('TOKEN'))
+ try:
+   keep_alive()
+   client.run(os.getenv('TOKEN'))
+ except Exception as e:
+   print(str(e))
+   os.system("kill 1")
```
`429 Too Many Requests (error code: 0)`などで長時間起動しないような事態がかなり減る。

## オンライン表示
恐らく、`keep_alive.py`みたいなファイル名に以下のようなコードが書いてあると思う。
```py
from flask import Flask
from threading import Thread

app = Flask('')

@app.route('/')
def main():
  return 'Bot is aLive!'
  
def run():
  app.run(host="0.0.0.0", port=8080)
  
def keep_alive():
  server = Thread(target=run)
  server.start()
```
これをそのまま`main.py`に移植すると、便利な表示機能が作れる。
```diff_python:main.py
- import keep_alive
+ from flask import Flask
+ from threading import Thread
+ 
+ app = Flask('')
+ @app.route('/')
+ def main():
+   #HTMLコードをreturnする
+   return "<html>Hello</html>"
+  
+ def run():
+   app.run(host="0.0.0.0", port=8080)
+ def keep_alive():
+   server = Thread(target=run)
+   server.start()
```
<details><summary> 実装例 </summary>

`ffadata.charadata`に格納されているセーブデータについて、レベルによる順位付けをオンラインで表示する。`def main():`以外は同じ
```py
def main():
  rankdata = sorted([[id,ffadata.charadata[id]["lv"]] for id in list(ffadata.charadata.keys())], key=lambda x:x[1], reverse=True)
  t="<br><midashi>FFAもどき レベルランキング</midashi><br><table><thead><tr><td></td><td></td><td></td></tr></thead><tbody>"
  for i in range(len(rankdata)):
    data=rankdata[i]
    try:
      t+="<tr><td>"+str(i+1)+"位</td><td>"+iroiro.idname[data[0]]+"さん</td><td>lv"+"{:,}".format(data[1])+"</td></tr>"
    except:
      t+="<tr><td>"+str(i+1)+"位</td><td>id"+str(data[0])+"さん</td><td>lv"+"{:,}".format(data[1])+"</td></tr>"
  return """<!DOCTYPE html><html><head>
<title>Atmk Bot 24h Online Review</title>
<style type="text/css">
midashi{font.size=150%; font-weight: bold;}
</style></head><body>
<midashi>Atmk bot 24h オンライン表示</midashi><div id="DateTimeDisp"></div>
※1分経過で自動更新されます<br>
"""+t+"""<script>
setTimeout(function () {location.reload();}, 60000);var now = new Date();
var target = document.getElementById("DateTimeDisp");
var Year = now.getFullYear();
var Month = now.getMonth()+1;
var Date = now.getDate();
var Hour = now.getHours();
var Min = now.getMinutes();
var Sec = now.getSeconds();
target.innerHTML = Year + "/" + Month + "/" + Date + " " + Hour + ":" + Min + ":" + Sec;
</script></body></html>"""
```



</details>

# 便利コピペ

## db使用セーブデータクラス

```py
class Savedata:
  def __init__(self,name):
    self.savename=[name]
    if self.savename[0] in db:
      self.load()
  
  def save(self):
    db[self.savename[0]]="\n".join(["self."+attr+"="+str(eval("self."+attr)) for attr in dir(self) if not callable(getattr(self,attr)) and not attr.startswith("__")])

  def load(self):
    exec(db[self.savename[0]])
```

<details><summary>使用例</summary>

```py
class Iroiro(Savedata):
  def __init__(self,name="iroiro"):
    self.counta=dict()
    super().__init__(name)

iroiro=Iroiro()

@client.event
async def on_message(message):
  if message.content=="あ":
    if not message.author.id in iroiro.counta:
      iroiro.counta[message.author.id]=0
    iroiro.counta[message.author.id]+=1
    iroiro.save()
```
</details>
小分けにするとめんどくさいsave, loadをまとめて実装できる。

:::note
`with open(ファイル名) as f:`などの構文を利用すれば、replit未使用でも利用可。
:::

:::note alert
この`Savedata`クラスを使う場合、**str型をデータに含める場合は必ず`["hogehoge"]`など、str型でない形** にすること。(上記の`save`関数では、str型をstr型として保存できない。)
```diff_python
- #NG
- self.strvalue = "hogehoge"
- #save()後にload()すると以下のように解釈され、エラーとなる
- self.strvalue = hogehoge

+ #OK
+ self.strvalue = ["hogehoge"]
+ #この場合は正常に保存・読込が行われる。
```
:::

## txtファイルのみの発言の際、txtファイルの中身を本文と解釈する

```python
@client.event
async def on_message(message):
  CONT=message.content
  if CONT=="":
    try:
      r=requests.get(message.attachments[0].url, stream=True, timeout=(3,7))
      r.encoding="utf-8"
      if r.status_code == 200:
        CONT=r.text.replace("\r","")
    except:
      pass
```
これを入れた後、`message.content`の代わりに`CONT`を使えばよい。

## 管理者用デバッグ機能

```python
@client.event
async def on_message(message):
  if ID==管理者のID:
    if CONT.startswith(">debug"):
      if not "\n" in CONT:
        try:
          await sendmes(message,str(eval(CONT[7:])))
        except Exception as e:
          await sendmes(message,str(e))
      elif CONT.endswith("```"):
        try:
          codes = "\n".join(CONT.split("\n")[2:-1])
          exec("mes=''\ndef print(text):\n  global mes\n  mes+=str(text)+'\\n'\n"+codes)
          global mes
          if mes=="":
            await sendmes(message,"DONE")
          else:
            await sendmes(message,mes)
        except Exception as e:
          await sendmes(message,str(e))
      else:
        try:
          await sendmes(message,CONT[7:])
        except:
          pass
```
`>debug 変数名`と入れれば変数名の中身が出力され、
~~~
>debug
```python
pythonコード
```
~~~
と入力すればpythonコードが実行される。また、
```
>debug
文章
```
と入力すれば文章をそのまま出力する。

## 2000文字超過のテキストをtxtファイルに変換 & 編集・削除でbotの発言を更新

```python
class Zatuyou:
  def __init__(self):
    self.meslist=dict()

zatuyou=Zatuyou()

@client.event
async def sendmes(message,text,memo=False):
  if memo==False and len(text)<2000:
    botmes=await message.channel.send(text)
  else:
    with open("file/message.txt",mode="w",encoding='utf-8') as textfile:
      textfile.write(text)
    botmes=await message.channel.send(file=discord.File("file/message.txt"))
  if message.author.id!=このbotのID:
    if not message.id in zatuyou.meslist:
      zatuyou.meslist[message.id]=set()
    zatuyou.meslist[message.id].add(botmes)
    if len(zatuyou.meslist)>=100: #ここの数字はメモリ上限が許す限り任意
      zatuyou.meslist=dict()

@client.event
async def trydelete(message,text=""):
  try:
    await message.delete()
  except:
    if text=="":
      pass
    else:
      await sendmes(message,text)

@client.event
async def on_message_edit(before, after):
  if not after.author.bot:
    if after.id in zatuyou.meslist:
      for MES in zatuyou.meslist[after.id]:
        await trydelete(MES)
      del zatuyou.meslist[after.id]
    await on_message(after)

@client.event
async def on_message_delete(message):
  if not message.author.bot:
    if message.id in zatuyou.meslist:
      for MES in zatuyou.meslist[message.id]:
        await trydelete(MES)
      del zatuyou.meslist[message.id]
```
`sendmes`の後半部分では、botの発言内容をメッセージid毎に保存する機能がある。保存した`message`を参照して、可能ならば消す機能(`trydelete`)を`on_message_edit, on_message_delete`に入れてある。

## ランキング

```python
class Iroiro(Savedata):
  def __init__(self,name="iroiro"):
    self.idname=dict()
    super().__init__(name)

iroiro = Iroiro()

@client.event
async def sendrank(message,data,title,tanni):
  rankdata = sorted(data.items(), key=lambda x:x[1],reverse=True)
  text=title+"\n"
  j=1
  for i in range(len(rankdata)):
    data=rankdata[i]
    try:
      user=iroiro.idname[data[0]]
      if data[0]==message.author.id:
        text+="**"+str(j)+" 位 "+user+" さん : "+"{:,}".format(data[1])+" "+tanni+"**\n"
      else:
        text+=str(j)+" 位 "+user+" さん : "+"{:,}".format(data[1])+" "+tanni+"\n"
    except:
      text+=str(j)+" 位 id "+str(data[0])+" さん : "+"{:,}".format(data[1])+" "+tanni+"\n"
    if i<len(rankdata)-1 and not data[1]==rankdata[i+1][1]:
      j+=1
  await sendmes(message,text)

@client.event
async def on_message(message):
  ID=message.author.id
  NAME=message.author.name
  if not message.author.bot:
    if not ID in iroiro.idname:
      iroiro.idname[ID]=NAME
      iroiro.save()
```
例えば`await sendrank(message,iroiro.counta,"「あ」発言回数ランキング", "回")`のように使う。

## 自作エラー

```python
class BotError(Exception):
  pass

import traceback

@client.event
async def on_message(message):
  if not message.author.bot:
    try:
      if message.content=="「あ」大好き":
        if iroiro.counta[message.author.id]<100:
          raise BotError("もっと「あ」と発言してください")
        await message.channel.send("そうですね、あなたは実際"+str(iroiro.counta[message.author.id])+"回「あ」と発言しました")
    except:
      await message.channel.send(message.author.name+"さん"+"`"*3+"\n"+traceback.format_exc()+"`"*3)
```

## メッセージの反応幅を広げる
正規表現を使うとよい。[ここ](https://murashun.jp/article/programming/regular-expression.html)を見れば全コマンドが分かる。[ビジュアライザー](https://jex.im/regulex/#!flags=&re=%5E(a%7Cb)*%3F%24)もある。
```python
import re

class Zatuyou:
  def __init__(self):
    self.multia=re.compile(u"^[aAあア]+$")

zatuyou = Zatuyou()

@client.event
async def on_message(message):
  if zatuyou.taika.match(CONT):
    if ID in iroiro.counta:
      iroiro.counta[ID]+=1
    else:
      iroiro.counta[ID]=1
```
上記の場合、`a,A,あ,ア`の4種類のみからなる文章全てに反応する。