discord.pyでメンバー一括ミュート&解除bot～ポモドーロ添え～


もくりヘビーユーザーだった。
哀しい事にもくりは旅立ってしまった。
ので疑似集中モードを作ってdiscordに導入した。
けどものすごく右往左往したので書き記すことにした。


:::note warn
botの中身のみの記事になります。
discordのbotそのものの作成方法については扱いませんので、下記を参考に作成してください。

[Botアカウント作成(公式ドキュメント)](https://discordpy.readthedocs.io/ja/latest/discord.html 'Botアカウント作成')

:::

## やりたいこと

+ ボイチャに参加しているメンバーをコマンド一つで全員サーバーミュートにする
+ ポモドーロタイマーの機能を持たせ、集中時間は全員ミュート&休憩時間はミュート解除になるようにする
+ ボイチャに途中参加したメンバーを最初からサーバーミュートにする
+ ミュートとミュート解除のタイミングで好きな音を流したい
+ Pythonで作りたい

## 用意したもの
+ Windows 11
+ Python 3.12.3
+ discord.py 2.3.2
+ ffmpeg（音を流すのに必要）
+ 流したい音声ファイル

## できたもの

※25/3/13 諸々修正
```python:mute.py

import discord
from discord.ext import commands
from discord import app_commands
import asyncio
import os
from dotenv import load_dotenv

load_dotenv()
TOKEN = os.getenv('DISCORD_BOT_TOKEN')

intents = discord.Intents.all()
intents.message_content = True
intents.voice_states = True
intents.guilds = True
intents.members = True

bot = commands.Bot(
    command_prefix="$",
    case_insensitive=True,
    intents=intents
)

guild_id = 'id'

@bot.event
async def on_ready():
    print(f'{bot.user}がログインしました')
    await bot.tree.sync()

@bot.hybrid_command(name="go",description="集中モード起動コマンド")
@app_commands.describe(times="集中時間(分)", count="何周やるか(周)" ,brk="休憩時間(分)")
async def go(ctx: commands.Context, times: int, count: int ,brk: int):
    # time:集中時間 count:何周やるか brk:休憩時間
    if ctx.author.voice is None:
        await ctx.channel.send("ボイスチャンネルに接続してからもう一度実行してください。")        
        return
    
    if not ctx.guild.voice_client in bot.voice_clients:
        await ctx.author.voice.channel.connect()# 音を鳴らすためにボットをボイチャに参加させる        
    else:
        await ctx.channel.send("既に実施中です。サイクルが終了するまでお待ちください。")
        return
    
    global guild_id
    guild_id = ctx.author.voice.channel.guild.id# ボットが参加したボイチャのサーバのidを判別
    voice_channel = ctx.author.voice.channel
    a = times * 60
    b = brk * 60
    await ctx.channel.send( str(times)+ "分集中/" + str(brk) + "分休憩を" + str(count) + "周します")

    for i in range(count): # 周指定
        flag = False # botだけ残らないように このあたり関数にした方が良い
        if ctx.author.voice is None:
            await ctx.guild.voice_client.disconnect()
            flag = True
            await ctx.channel.send("botが居ないのでサイクルを終了します。")
            break
        elif not ctx.guild.voice_client in bot.voice_clients:
            flag = True
            await ctx.channel.send("botが居ないのでサイクルを終了します。")
            break
        else:
            pass
        if flag:
            break
        for member in voice_channel.members:

            if member.bot:
                continue
            await member.edit(mute=True) # チャンネルの各参加者をミュートする
            
        await ctx.channel.send("ミュートにしました\n" + str(i+1) + "回目の作業開始\n" + str(times) +"分後にお知らせします。")
        await asyncio.sleep(a)

        if ctx.author.voice is None:
            await ctx.guild.voice_client.disconnect()
            flag = True
            await ctx.channel.send("botが居ないのでサイクルを終了します。")
            break
        elif not ctx.guild.voice_client in bot.voice_clients:
            flag = True
            await ctx.channel.send("botが居ないのでサイクルを終了します。")
            break
        else:
            pass
        if flag:
            break
        for member in voice_channel.members:
            
            if member.bot:
                continue
            await member.edit(mute=False)

        voice_client = ctx.guild.voice_client
        music = "休憩突入時に鳴らしたい曲.mp3"
        ffmpeg_audio_source = discord.PCMVolumeTransformer(discord.FFmpegPCMAudio(music),volume=0.2)
        voice_client.play(ffmpeg_audio_source)
       
        if i != count-1:
            await ctx.channel.send( str(brk) + "分休憩しましょう")
            await asyncio.sleep(b)
            if ctx.author.voice is None:
                await ctx.guild.voice_client.disconnect()
                flag = True
                await ctx.channel.send("botが居ないのでサイクルを終了します。")
                break
            elif not ctx.guild.voice_client in bot.voice_clients:
                flag = True
                await ctx.channel.send("botが居ないのでサイクルを終了します。")
                break
            else:
                pass
            
            if flag:
                break
            music_end = "作業再開時に鳴らしたい曲.mp3"
            ffmpeg_audio_source_end = discord.FFmpegPCMAudio(music_end)
            voice_client.play(ffmpeg_audio_source_end)
            await ctx.channel.send( str(brk) + "分休憩終わりです。また頑張りましょう。")
            await asyncio.sleep(15) # 曲の長さによって変えます
        else:
            await ctx.channel.send( str(count) + "周終わりました")
            await asyncio.sleep(5)
            await ctx.channel.send( "おつ！もう一回するならコマンド入れてな！")
            await asyncio.sleep(5)
    else:
        await ctx.guild.voice_client.disconnect() #FIN

@bot.hybrid_command(name="cut",description="botだけ残ってた時用の切断コマンド")
async def cut(ctx:commands.Context):
    if ctx.guild.voice_client in bot.voice_clients:
        await ctx.guild.voice_client.disconnect()
    else:
        await ctx.channel.send("接続されていません")
        return

@bot.hybrid_command(name="join",description="botだけ抜けた時の再参加コマンド")
async def join(ctx:commands.Context):
    if not ctx.guild.voice_client in bot.voice_clients:
        await ctx.author.voice.channel.connect()
        await ctx.channel.send("サイクル実施中だった場合はこのままお待ちください。")
    else:
        await ctx.channel.send("接続されています")
        return

@bot.event
async def on_voice_state_update(member, before, after):
    if member.guild.id == guild_id and (before.channel != after.channel):
       memlist = []
       if  before.channel is None:
           if member.bot:
                return
           for member in after.channel.members:
               memlist.append(member)
               user_count = sum(1 for member in memlist if not member.bot)
           print(f'{guild_id}ユーザ数：{user_count}')
           if memlist[0].voice.mute == True:
               await member.edit(mute=True)
           else:
               await member.edit(mute=False)
       elif before.channel and not after.channel and len(before.channel.members) == 0:
            print(f'{guild_id}には誰もおらんで')
       else:
            print(f'{member.name}が抜けたで')
            if member.bot:
                return

bot.run(TOKEN)
```

実行はコマンドプロンプトを起動して
```
$ python mute.py
```
でOK
あとはbotを招待したサーバのボイチャで
```
Ex.25分集中で4周、休憩時間は5分

# 通常コマンド
$go 25 4 5

or

# スラッシュコマンド
/go 25 4 5
```
を叩けば開始される。
私は一つのサーバにチャンネル複数作ってないのでチャンネルの指定も何もないけど、いっぱいある人は`if ctx.channel.id != チャンネルID`とかで指定しておいた方が良いとは思う。
トークンは直接書かない方がいいのでdotenvで引っ張ってきてます。

## やりたいこと各所
少しずつ書いて、最後に全部組み合わせた。
※下記の内容は初期のものなので若干異なりますが、やってる事はおおむね一緒です。

### メンバー全員をミュート/ミュート解除する
ドキュメントと先駆者の記事を参考に、ギルドミュート（サーバーミュート）する`edit(mute='True)`と、ボイチャに参加中のメンバーを巡る `for member in hoge.members:`を組み合わせる事でいけた。

```python
@bot.hybrid_command(name="go",description="一斉ミュートコマンド")
async def test(ctx):

    voice_channel = ctx.author.voice.channel
    # コマンド送信者のボイチャを指定
    
    for member in voice_channel.members:
        await member.edit(mute=True) # チャンネルの各参加者をミュートする

    await ctx.channel.send("ミュートにしました\n〇分後にお知らせします。")
    await asyncio.sleep(a) #　ミュート解除までの秒数を指定(ex.25分=1500)

    for member in voice_channel.members:
        await member.edit(mute=False)
```

これだけで事足りるとは思わなかった。

### ポモドーロタイマーの機能を持たせ、集中時間は全員ミュート&休憩時間はミュート解除になるようにする

`asyncio.sleep(sec)`に加えて`for i in range(int)`を使用した。
またそれにプラスして、コマンド送信時にオプションで「集中時間」「何周やるか」「休憩時間」も指定できるようにした。

```diff_python
@bot.hybrid_command(name="go",description="一斉ミュートコマンド")
-async def test(ctx):
+async def test(ctx, times: int, count: int ,brk: int):
    voice_channel = ctx.author.voice.channel
+   a = times * 60
+   b = brk * 60
    # 秒数に直す

    for i in range(count): # count周する
        for member in voice_channel.members:
            await member.edit(mute=True)
        
-       await ctx.channel.send("ミュートにしました\n〇分後にお知らせします。")
+       await ctx.channel.send("ミュートにしました\n" + str(i+1) + "回目の作業開始\n" + str(times) +"分後にお知らせします。")
        await asyncio.sleep(a)

        for member in voice_channel.members:
            await member.edit(mute=False)
               
+       if i != count-1:
+           await ctx.channel.send( str(brk) + "分休憩しましょう")
+           await asyncio.sleep(b)

+           await ctx.channel.send( str(brk) + "分休憩終わりです。また頑張りましょう。")
+       else:
+           await ctx.channel.send( str(count) + "周終わりました")
+   else:
+       await ctx.channel.send("おつ！")

```
周回して、指定した回数分より少なければループ、回数分になったらループを抜ける旨をメッセージで送れるようにした。

:::note info
数字として受け取った値をメッセージに出す場合は`str(hoge)`のように文字列にする事を忘れないこと
:::

### ボイチャに途中参加したメンバーを最初からサーバーミュートにする

これは別のイベントでの記述になる。

```python
@bot.event
async def on_voice_state_update(member, before, after):
    if before.channel != after.channel:
        if before.channel is None: 
            await member.edit(mute=True)
```
`on_voice_state_update(member, before, after)`で、ボイチャの状態変化をキャッチする。ここではメンバーと変化する以前、変化した後をキャッチするように指定している。
`if before.channel is None: `で`await member.edit(mute=True)`としているが、これは「前に居らんかったmemberをミュートにしろ」という指示になる。
ここの`member`には参加したユーザーが入っているので注意。

ただ問題があって、**ゲスト招待にした場合、この箇所も含めた前述のミュート機能そのものがダメになった。**
ので、通常のサーバ（チャンネル）招待にして、ボイチャから退出したらゲスト招待の時と同様にキックする方法を取った。それで変更後がこれ。

```diff_python
@bot.event
async def on_voice_state_update(member, before, after):
    if before.channel != after.channel:
+       memberlist = []
       if before.channel is None:
+           for member in after.channel.members:
+               memberlist.append(member)

+          if memberlist[0].voice.mute == True:
              await member.edit(mute=True)
+          else:
+              await member.edit(mute=False)
+      else:
+           if member.bot:
+               return
+           else:
+               await member.kick( reason = None )
```
```if memberlist[0].voice.mute == True:```で「今ボイチャに参加してるやつで一番最初のやつ（大体私本人かBOTのはず）がサーバーミュート状態だったら」という形にしてる。作業時間中にボイチャに参加した人をミュートにするための処理がこの後の```member.edit(mute=**)```にあたる。
ボイチャから抜けた際に、抜けた対象を```member.kick( reason = None )```でキックする事により、これでゲスト招待と同じようになる。ただしBOTをキックさせるわけにはいかないので、```if member.bot:```は抜けたのがBOT本体だった場合にBOTをキックしないようにするため。タイマー終わったらBOTは出て行っちゃうからその防止。

### ミュートとミュート解除のタイミングで好きな音を流したい
これは[ffmpeg](https://ffmpeg.org/ 'ffmpeg')が別途必要になるので入れて、かつBOTをボイチャに参加させる。

```diff_python
@bot.hybrid_command(name="go",description="一斉ミュートコマンド")
async def test(ctx, times: int, count: int ,brk: int):
    await ctx.author.voice.channel.connect()
+   voice_channel = ctx.author.voice.channel
    a = times * 60
    b = brk * 60

    for i in range(count):
        for member in voice_channel.members:
            await member.edit(mute=True)
            
        await ctx.channel.send("ミュートにしました\n" + str(i+1) + "回目の作業開始\n" + str(times) +"分後にお知らせします。")
        await asyncio.sleep(a)

        for member in voice_channel.members:
            await member.edit(mute=False)
        
+       voice_client = ctx.guild.voice_client
+       music = "turnapage.mp3"
+       ffmpeg_audio_source = discord.PCMVolumeTransformer(discord.FFmpegPCMAudio(music),volume=0.2)
+       voice_client.play(ffmpeg_audio_source)
        
        if i != count-1:
            await ctx.channel.send( str(brk) + "分休憩しましょう")
            await asyncio.sleep(b)
+           music_end = "pon_01.mp3"
+           ffmpeg_audio_source_end = discord.FFmpegPCMAudio(music_end)
+           voice_client.play(ffmpeg_audio_source_end)
            await ctx.channel.send( str(brk) + "分休憩終わりです。また頑張りましょう。")
+           await asyncio.sleep(15)
        else:
+           await asyncio.sleep(46)
            await ctx.channel.send( str(count) + "周終わりました")
    else:
-       await ctx.channel.send("おつ！")
+       await ctx.guild.voice_client.disconnect()
```
鳴らす音についてはパスで指定すること。多分なんでもいける。
turnapage.mp3の方はもともとの音量が大きいので、`discord.PCMVolumeTransformer()`でボリュームを調整している。


これで自前の環境でなら疑似集中モードを実装することができた。
もし可能なら作業時間残り何分のようなカウントダウンタイマーも付けたいけどちょっと難しそう。


**もくりありがとう**

## 参考
感謝してもしきれない　ありがとうございます

discord.py公式ドキュメント
https://discordpy.readthedocs.io/ja/latest/index.html
https://discordpy.readthedocs.io/ja/latest/api.html
https://discordpy.readthedocs.io/ja/latest/ext/commands/index.html

discord.py入門
https://qiita.com/sizumita/items/9d44ae7d1ce007391699

Pythonで実用Discord Bot(discordpy解説)
https://qiita.com/1ntegrale9/items/9d570ef8175cf178468f

discord.py - commandsフレームワークへの移行
https://zenn.dev/mnonamer/articles/272f53a1d300f9

discord.pyでボイスチャンネルにいるメンバーを取得する
https://qiita.com/NightWatchWife/items/569d9a5d4cb7d094d1f3