チームワーキングのプロダクティビティをインクリメントする意識の高いdiscord bot
はじめに
こんにちは。NAIST Advent Calendar 2022の3日目担当の人です。
今回はdiscordのvoice channel上でチーム作業を行う人たち向けのbotを制作したので、記事に残しておこうと思います。
本当はFairseq(深層学習フレームワーク)の内部実装の記事を書きたかったけどニッチすぎたのでやめた。また別の機会に投稿します。
botの仕様
discordって、channel内の人がvoice channelに入っても通知が来ないんですよね。なので、誰かがvoice channelに入った際に通知が届くと幸せになる人は多いと思うんです。
あと、もしも何かしら共同で作業する場面がある際に、作業時間が計上されれば少しモチベーションが上がりますよね(あくまでうちのチームでのニーズですが)。
なので、今回は以下の機能を持つdiscord botを開発してみました。
- 誰かがvoice channelに入室した際に通知
- 特定のvoice channel内での作業時間を計上・通知
- 簡単なログ機能
実際に動かしてみると、こんな感じです。
実装環境
- 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
を作成して書き込んでいきましょう。
{
"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も定義しておきます。
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
クラスを用意していきます。
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
クラスも用意します。
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
に以下を追記します。
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の状態に何かしらの変化が発生した場合に実行されます。
# 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.channel
やafter.channel
の中身を見て、入室、退室または移動かどうかを判定し、それに応じた動作を行います。
入室の場合
- もし入室先が
working_room_ids
の一覧にあれば、作業時間の計上を開始 - 「〇〇が〇〇に入室」と通知
退室の場合
- 作業時間の計上を停止
- 「〇〇が〇〇を退室」と通知
移動の場合
- もし
working_room_ids
の一覧にある部屋から出た場合は作業時間の計上を停止 - もし
working_room_ids
の一覧にある部屋に入った場合は作業時間の計上を開始 - 「〇〇が〇〇に移動」と通知
作業時間通知の実装
最後に、日ごと・週ごとに作業時間を通知する部分を実装しましょう。
discord.pyでは特定の時間になったらアクションを起こすという事が難しいみたいなので、ちょっとゴリ押しでやります。
やり方としては、@tasks.loop()
デコレータを用いて、1分ごとに関数を実行します。関数の実行時に、特定の時刻になっていれば、作業時間を通知するように実装します。
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"をクリックします。
規約に同意して新しいApplicationを作成します。
作成が完了したら、Botタブより、"Add Bot"をクリックします。
"Reset Token"をクリックして、
tokenの文字列が表示されるので、"Copy"をクリックしてコピーしましょう。
ただし、このtokenが他者にバレると、botを通じてメッセージの閲覧や操作等が可能となってしまうので必ず秘密にしましょう。
Botをチャンネルに招待
では、botをチャンネルに招待しましょう。
まずその前に、Privilegeを設定します。とりあえず全てenableにしておきましょう。
次に招待用のURLを生成します。"OAuth2">"URL Generator"より、以下の項目を選択し、URLにアクセスします。
- SCOPES
- bot
- BOT PERMISSIONS
- Send Messages
自分の招待したいサーバを選択して、botを招待しましょう。
これで、無事にchannelにbotが追加されれば完了です。
- Send Messages
channel idの取得
botの招待が完了すれば、channel idを取得する事が可能になります。以下のコードを書いて取得しましょう。
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が起動して、入退室の通知が届くはずです。
また、作業時間については以下のように通知してくれます。
いい感じですね。ログもこのような感じで出力してくれています。
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のリポジトリはここにあります。
最後まで読んでいただきありがとうございました。
Discussion