🤖

チームワーキングのプロダクティビティをインクリメントする意識の高いdiscord bot

2022/12/03に公開

はじめに

こんにちは。NAIST Advent Calendar 2022の3日目担当の人です。

今回はdiscordのvoice channel上でチーム作業を行う人たち向けのbotを制作したので、記事に残しておこうと思います。

本当はFairseq(深層学習フレームワーク)の内部実装の記事を書きたかったけどニッチすぎたのでやめた。また別の機会に投稿します。

botの仕様

discordって、channel内の人がvoice channelに入っても通知が来ないんですよね。なので、誰かがvoice channelに入った際に通知が届くと幸せになる人は多いと思うんです。
あと、もしも何かしら共同で作業する場面がある際に、作業時間が計上されれば少しモチベーションが上がりますよね(あくまでうちのチームでのニーズですが)。

なので、今回は以下の機能を持つdiscord botを開発してみました。

  • 誰かがvoice channelに入室した際に通知
  • 特定のvoice channel内での作業時間を計上・通知
  • 簡単なログ機能

実際に動かしてみると、こんな感じです。
discord botの完成系

実装環境

  • Python 3.10.8
  • discord.py 1.7.3

versionが異なると本記事での実装内容が動作しない可能性があるのでご了承ください。
以下のコマンドを実行して必要なパッケージをインストールしておきましょう。

$ pip install discord.py==1.7.3

また、以下のようなファイル構造で作業していきます。

discord-voice-channel-alert-bot
├── discordbot.py
├── get_room_id.py
├── modules.py
└── settings.json

実装

settingの読み込み

まずは、ある程度の柔軟性を持たせるために、settingをjsonファイルに書き込めるようにしましょう。設定項目は以下の通りです。

項目 説明
token discord botのtokenです。Discord Developer Portalにbotを登録する事により入手する事が出来ます。公開してはいけません。
message_room_id botが通知を送信するtext channelのidです。
is_enable_working_time_alert 作業時間についてのアラートを送信するかどうかを決定します。
lang 通知の言語です。"en"か"ja"から選びます。
working_room_ids 作業時間を計上する対象の部屋のリストです。複数指定可能です。
weekly_alert_weekday 週ごとの作業時間を通知する曜日を指定します。0が月曜日、1が火曜日...のように整数で指定します。
weekly_alert_time 週ごとの作業時間を通知する時間を指定します。HH:MMの形式です。
daily_alert_time 日ごとの作業時間を通知する時間を指定します。HH:MMの形式です。

では、settings.jsonを作成して書き込んでいきましょう。

settings.json
{
    "token": "****",
    "message_room_id": 1234567890,
    "is_enable_working_time_alert": true,
    "lang": "ja",
    "working_room_ids": [
        1234567890,
	9876543210
    ],
    "weekly_alert_weekday": 0,
    "weekly_alert_time": "21:00",
    "daily_alert_time": "00:00"
}

tokenやroom idはテキトーに書いていますが、適宜適したものを書いてください。
これらのの取得方法については後述します。

では、これらの設定を読み込みましょう。discordbot.pyに直接書き込んでいきます。ついでにloggerも定義しておきます。

discordbot.py
import logging
import json
from copy import copy
import os

# set the logging
logger = logging.getLogger("discordbot logger")
logger.setLevel(logging.INFO)

stream_handler = logging.StreamHandler()
logger.addHandler(stream_handler)
file_handler = logging.FileHandler("discordbot.log", encoding="utf-8", mode="w")
logger.addHandler(file_handler)

formatter = logging.Formatter("%(asctime)s | %(levelname)s | %(message)s")
stream_handler.setFormatter(formatter)
file_handler.setFormatter(formatter)

# if `settings.json` exists, read it
if os.path.exists("./settings.json"):
    with open("./settings.json", mode="r") as setting_file:
        settings:dict = json.load(setting_file)
    
    # catch the exception by assertion
    assert "token" in settings.keys()
    assert "message_room_id" in settings.keys()
    
    # set the default values
    settings["is_enable_working_time_alert"] = settings.get("is_enable_working_time_alert", False)
    settings["weekly_alert_weekday"] = settings.get("weekly_alert_weekday", -1)
    settings["weekly_alert_time"] = settings.get("weekly_alert_time", "none")
    settings["daily_alert_time"] = settings.get("daily_alert_time", "none")
    
    if "lang" in settings.keys():
        if not settings["lang"] in ["ja", "en"]:
            raise ValueError("{0} is not supported as language".format(settings["lang"]))
    else:
        settings["lang"] = "en"
    
    settings_without_token = copy(settings)
    settings_without_token["token"] = "****************"
    logger.info("initialized by setting as folows:\n{}".format(json.dumps(settings_without_token, indent=4)))
else:
    raise FileNotFoundError("`settings.json` does not exist")

これで、設定をdict型で読み込む事ができるようになりました。

作業時間を記録するモジュール

作業時間を記録するために、いろいろとめんどくさい処理が必要になりそうです。なので、先に作業時間を管理してくれるモジュールを用意して一任してしまいましょう。modules.pyを作成し、まずは時間管理を一任するWorkingTimeクラスを用意していきます。

modules.py
import datetime

class WorkingTime:
    def __init__(self, start_time=None) -> None:
        self._working_time = datetime.timedelta(
            days=0,
            seconds=0,
            microseconds=0,
            milliseconds=0,
            minutes=0,
            hours=0,
            weeks=0,
        )
        
        # about working time calculation
        self.start_time = start_time
        self._is_working = False
    
    def is_working(self) -> None:
        return self._is_working
    
    def end_working(self) -> None:
        self._working_time += datetime.datetime.now() - self.start_time
        self.start_time = None
        self._is_working = False
    
    def start_working(self) -> None:
        self.start_time = datetime.datetime.now()
        self._is_working = True
        
    def reset_working_time(self) -> None:
        self._working_time = datetime.timedelta(
            days=0,
            seconds=0,
            microseconds=0,
            milliseconds=0,
            minutes=0,
            hours=0,
            weeks=0,
        )
    
    def get_working_time(self) -> None:
        return self._working_time

    def __str__(self) -> str:
        working_total_seconds = int(self._working_time.total_seconds())
        working_total_hours = working_total_seconds // 3600
        working_minutes = working_total_seconds % 3600 // 60
        
        return "{0:0=3}H{1:0=2}M".format(working_total_hours, working_minutes)

このクラスには以下の機能があります。

  • is_working()
    • 今作業しているかどうかを取得。
  • start_working(), end_working()
    • 作業開始時刻と作業終了時刻の時間差分を計算し、作業時間を計上する。
    • 作業時間はdatetime.timedeltaで管理する。
  • reset_working_time()
    • 作業時間をリセット。
  • get_working_time()
    • datetime.timedelta型で管理している作業時間を0にする。
  • __str__()
    • HHH:MMの形式で作業時間を取得。

ただし、複数人の情報を管理しなければならないので、ユーザと紐づけてまとめて管理できるWorkingRecordsクラスも用意します。

modules.py
from typing import List, Dict
from discord import Member

class WorkingRecords:
    def __init__(self,) -> None:
        self._working_time_dict: Dict[str, WorkingTime] = {}
        self._id_to_display_name: Dict[int, Member] = {}
    
    def update_member(self, member:Member) -> None:
        self._id_to_display_name[member.id] = member
    
    def id_to_member(self, user_id:int) -> Member:
        return self._id_to_display_name[user_id]
    
    def reset_records(self,) -> None:
        new_working_time_dict = {}
        for user_name, working_time in self._working_time_dict.items():
            if working_time.is_working():
                working_time.reset_working_time()
                working_time.start_working()
                new_working_time_dict[user_name] = working_time
        self._working_time_dict = new_working_time_dict
    
    def start_record(self, member:Member) -> None:
        self.update_member(member)
        if not member.id in self._working_time_dict.keys():
            self._working_time_dict[member.id] = WorkingTime()
        
        if self._working_time_dict[member.id].is_working():
            return
        else:
            self._working_time_dict[member.id].start_working()
    
    def stop_record(self, member:Member) -> None:
        self.update_member(member)
        if member.id in self._working_time_dict.keys() and self._working_time_dict[member.id].is_working():
            self._working_time_dict[member.id].end_working()
    
    def get_sorted_working_records(self) -> str:
        # shape of [(user_id, working_time), ...]
        for user_id in self._working_time_dict.keys():
            if self._working_time_dict[user_id].is_working():
                member = self.id_to_member(user_id)
                self.stop_record(member)
                self.start_record(member)
        
        working_times_sorted: List[tuple] = sorted(
            self._working_time_dict.items(),
            key=lambda x:x[1].get_working_time(),
        )
        working_times_sorted.reverse()
        
        working_times_str = "```\n"
        if len(working_times_sorted) == 0:
            working_times_str += "No one worked..."
        else:
            for working_time in working_times_sorted:
                user_display_name = self.id_to_member(working_time[0]).display_name
                user_working_time = working_time[1]
                working_times_str += "{0}\t{1}\n".format(user_display_name.ljust(15, " "), user_working_time)
        working_times_str += "```"
        
        return working_times_str

このクラスには以下の機能があります。

  • update_member(), id_to_member()
    • ユーザの固有IDとDisplay Nameを結びつける。
  • start_record(), stop_record()
    • 指定したユーザの作業時間の記録を開始・終了する。
  • reset_record()
    • 作業時間をリセットする。
  • get_sorted_working_records()
    • 作業時間で降順にソートされた作業時間一覧を文字列型で出力する。
    • 誰も作業しなかった場合はNo one worked...と出力。
    • 作業した場合は<display name> HHH:MMを一覧表示。

作業中に表示名などが変更されるとユーザの作業時間を追跡できなくなるので、念のためにユーザの表示名ではなくユーザの固有IDを用いて作業時間を追跡します。また、固有IDと表示名を結びつける事により、表示名と作業時間のペアを出力できるようにします。

このクラスを、botが時間管理をする際に実際に使っていきます。

bot起動時の挙動

やっとbot本体の実装に入ります。
まずは、bot起動時の挙動を定義しましょう。discordbot.pyに以下を追記します。

discordbot.py
import discord

from modules import WorkingRecords

# initialization of necessary instances
intents = discord.Intents.all()
client = discord.Client(intents=intents)

weekly_records = WorkingRecords()
daily_records = WorkingRecords()
message_room = None


@client.event
async def on_ready():
    # set the message room
    global message_room
    message_room = client.get_channel(settings["message_room_id"])
    
    # the setting for working time alert
    if settings["is_enable_working_time_alert"]:
        # initialization of user states
        for working_room_id in settings["working_room_ids"]:
            working_voice_channel = client.get_channel(working_room_id)
            for member_id in working_voice_channel.voice_states.keys():
                member = await client.fetch_user(member_id)
                weekly_records.start_record(member)
                daily_records.start_record(member)
                logger.info("{0} has already entered into working room".format(member.display_name))
        
        # initialization of working time alert
        show_working_times.start()
        logger.info("discord bot is activated!")
    
    # send activation message
    if settings["lang"] == "ja":
        await message_room.send(client.user.display_name +"が起動しました")
    else:
        await message_room.send(client.user.display_name +" is now activated")

client.run(settings["token"])

@client.eventをデコレータとして非同期処理の関数に付け加える事により、bot上でこの関数が動作するようになります。また、「いつ動作するか」というイベントの情報は、関数名で指定します。今回はbotが起動した場合の動作を定義したいので、on_ready()を用います。詳しくはAPI リファレンスをご覧ください。

この関数内では、

  • botが通知を行うtext channelを取得
  • もしユーザが既に対象のvoice channelにいた場合に、作業時間の計上を開始
  • 作業時間通知機能の開始(show_working_timesは後ほど実装します。)

を行います。

入退室通知の実装

次に、メインの機能である入退室通知の機能を実装していきます。
入退室と同時に作業時間の記録開始・停止もついでに行っておきましょう。今回はイベント関数としてon_voice_state_update()を用います。この関数は、voice channelの状態に何かしらの変化が発生した場合に実行されます。

discordbot.py
# alert of enter and exit
@client.event
async def on_voice_state_update(
        member:discord.Member,
        before:discord.VoiceState,
        after:discord.VoiceState
    ):
    # when bots activate this method, it does nothing
    if member.bot:
        return
    
    if before.channel != after.channel:
        # enter alert
        if before.channel is None:
            # update the state of working time
            if after.channel.id in settings["working_room_ids"]:
                weekly_records.start_record(member)
                daily_records.start_record(member)
            
            if settings["lang"] == "ja":
                message = member.display_name +'が'+ after.channel.name +'に入室'
            else:
                message = "{0} has entered to {1}".format(member.display_name, after.channel.name)
            
            logger.info(message)
            await message_room.send(message)

        # exit alert
        elif after.channel is None:
            # update the state of working time
            weekly_records.stop_record(member)
            daily_records.stop_record(member)
            
            if settings["lang"] == "ja":
                message = member.display_name +'が'+ before.channel.name +'を退室'
            else:
                message = "{0} has exited from {1}".format(member.display_name, before.channel.name)
            
            logger.info(message)
            await message_room.send(message)
        
        # change the room
        else:
            # update the state of working time
            if before.channel.id in settings["working_room_ids"] and not after.channel.id in settings["working_room_ids"]:
                # move from working room
                weekly_records.stop_record(member)
                daily_records.stop_record(member)
                
            elif not before.channel.id in settings["working_room_ids"] and after.channel.id in settings["working_room_ids"]:
                # move to working room
                weekly_records.start_record(member)
                daily_records.start_record(member)
            
            if settings["lang"] == "ja":
                message = member.display_name +'が'+ after.channel.name +'に移動'
            else:
                message = "{0} has moved to {1}".format(member.display_name, after.channel.name)
            
            logger.info(message)
            await message_room.send(message)

仕組みは簡単です。まず、botであるかそうでないかを判定し、botでない場合にのみ処理を開始します。次に、before.channelafter.channelの中身を見て、入室、退室または移動かどうかを判定し、それに応じた動作を行います。

入室の場合

  • もし入室先がworking_room_idsの一覧にあれば、作業時間の計上を開始
  • 「〇〇が〇〇に入室」と通知

退室の場合

  • 作業時間の計上を停止
  • 「〇〇が〇〇を退室」と通知

移動の場合

  • もしworking_room_idsの一覧にある部屋から出た場合は作業時間の計上を停止
  • もしworking_room_idsの一覧にある部屋に入った場合は作業時間の計上を開始
  • 「〇〇が〇〇に移動」と通知

作業時間通知の実装

最後に、日ごと・週ごとに作業時間を通知する部分を実装しましょう。
discord.pyでは特定の時間になったらアクションを起こすという事が難しいみたいなので、ちょっとゴリ押しでやります。

やり方としては、@tasks.loop()デコレータを用いて、1分ごとに関数を実行します。関数の実行時に、特定の時刻になっていれば、作業時間を通知するように実装します。

discordbot.py
from discord.ext import tasks

# alert of working time
@tasks.loop(seconds=60)
async def show_working_times():
    date = datetime.date.today()
    now_time = datetime.datetime.now()
    now = now_time.strftime("%H:%M")
    
    # weekly alert
    if date.weekday() == settings["weekly_alert_weekday"] and now == settings["weekly_alert_time"]:
        # the header of the message
        if settings["lang"] == "ja":
            message = "【今週の作業時間】\n"
        else:
            message = "[ weekly working times ]\n"
        
        message += weekly_records.get_sorted_working_records()
        
        weekly_records.reset_records()
        
        await message_room.send(message)
    
    # daily alert
    if now == settings["daily_alert_time"]:
        # the header of the message
        if settings["lang"] == "ja":
            message = "【今日の作業時間】\n"
        else:
            message = "[ today's working times ]\n"
        
        message += daily_records.get_sorted_working_records()
        
        daily_records.reset_records()
            
        await message_room.send(message)

また、通知時に作業時間をリセットします。
これにて、botの実装が完了しました。

動かしてみよう

では、実際にbotを動かしてみましょう!
実際にbotを動かすためには、tokenの情報とchannel idの情報が足りなかったですよね。これらのやり方について少し説明しておきます。

tokenの取得

tokenは、Discord Developer Portalにbotを登録する事により入手する事が出来ます。 まず、Discord Developer Portalにて、Applicationsのタブから"New Application"をクリックします。
New Applications
規約に同意して新しいApplicationを作成します。
Create an Application
作成が完了したら、Botタブより、"Add Bot"をクリックします。
Add Bot
"Reset Token"をクリックして、
Reset Token
tokenの文字列が表示されるので、"Copy"をクリックしてコピーしましょう。
Copy
ただし、このtokenが他者にバレると、botを通じてメッセージの閲覧や操作等が可能となってしまうので必ず秘密にしましょう。

Botをチャンネルに招待

では、botをチャンネルに招待しましょう。
まずその前に、Privilegeを設定します。とりあえず全てenableにしておきましょう。
Privilege
次に招待用のURLを生成します。"OAuth2">"URL Generator"より、以下の項目を選択し、URLにアクセスします。

  • SCOPES
    • bot
  • BOT PERMISSIONS
    • Send Messages

      自分の招待したいサーバを選択して、botを招待しましょう。
      Invite
      これで、無事にchannelにbotが追加されれば完了です。

channel idの取得

botの招待が完了すれば、channel idを取得する事が可能になります。以下のコードを書いて取得しましょう。

get_room_id.py
import discord
import json

intents = discord.Intents.all()
client = discord.Client(intents=intents)

with open("./settings.json", mode="r") as setting_file:
    setting_dict = json.load(setting_file)

TOKEN = setting_dict["token"]

@client.event
async def on_ready():
    for channel in client.get_all_channels():
        print("---------------")
        print("channel name", str(channel.name))
        print("channel ID", str(channel.id))
    print("---------------")

client.run(TOKEN)
$ python get_room_id.py
---------------
channel name テキストチャンネル
channel ID 939374841600020511
---------------
channel name ボイスチャンネル
channel ID 939374841600020512
---------------
channel name test_text
channel ID 939374841600020513
---------------
channel name test_voice_1
channel ID 939374841600020514
---------------
channel name test_vocie_2
channel ID 1039114735649570876
---------------

これで、全ての準備が整いました。tokenとchannel idをsettings.jsonに追記して、botを動かしてみましょう。

botの起動

では、以下のコマンドを実行します。

$ python discordbot.py

これでbotが起動して、入退室の通知が届くはずです。
Notification
また、作業時間については以下のように通知してくれます。
discord botの完成系
いい感じですね。ログもこのような感じで出力してくれています。

discordbot.log
2022-12-02 13:45:50,101 | INFO | initialized by setting as folows:
{
    "token": "****************",
    "message_room_id": 939374841600020513,
    "is_enable_working_time_alert": true,
    "lang": "ja",
    "working_room_ids": [
        939374841600020514
    ],
    "weekly_alert_weekday": 0,
    "weekly_alert_time": "21:00",
    "daily_alert_time": "09:10"
}
2022-12-02 13:45:53,037 | INFO | discord bot is activated!
2022-12-02 13:46:00,196 | INFO | Yuchnがtest_voice_1に入室
2022-12-02 13:46:02,285 | INFO | Yuchnがtest_vocie_2に移動
2022-12-02 13:46:03,902 | INFO | Yuchnがtest_voice_1に移動
2022-12-02 13:46:06,686 | INFO | Yuchnがtest_voice_1を退室

注意点

discord botをデプロイする先として、Herokuを用いる人が多いかと思います。今回の実装方法そのままでは、Heroku上でうまく動かすことが出来ません。Herokuは24時間ごとにコンテナが再起動されるため、クラス内の変数に保存されている情報がリセットされてしまい、累積作業時間の情報が消えてしまうからです。もしもHeroku上にデプロイする場合は、Heroku Redisなどを使いましょう。

また、settings.jsonにはtokenの情報が入っているので、必ず.gitignoreに書き込んでリモートリポジトリにpushしないように気をつけましょう。

おわりに

今回はdiscord.pyを用いてdiscord botの開発を行いました。非常にシンプルかつ簡単に作ることができるので、興味のある方は是非自分好みのbotを作ってみてください。

作成したbotのリポジトリはここにあります。
https://github.com/ChanYu1224/discord-voice-channel-alert-bot

最後まで読んでいただきありがとうございました。

Discussion