概要
Splatoon3の大会で連絡用に使うDiscordで動く、ロール付与を自動化するプログラムをPythonとGASを使って作ったので、ポートフォリオとしてまとめます
Discordでロール付与を自動化したいという方に少しは参考になると思います。手取り早く実装方法を見たい場合は実装方法からどうぞ
内容
実現したいこと
基本的にはロールを自動的に付与するプログラムにしたいですが、参加者のアイコンが大会のPVに必要とのことで、大会のDiscordサーバーで参加者がアイコンを提出したら必要なロールを付与されるプログラムの実装を目指します。
付与したいロールは以下の通り:
ロール | 条件 |
---|---|
大会ロール | なし |
リーダーロール | あり |
チームロール | あり |
大会ロールはアイコンを提出した人に一律、リーダーロールとチームロールはそれぞれの条件に従って付与したい。
構造
大会のDiscordサーバーでロールをプログラムで自動付与するためにはDiscordBotを作る必要があります。これにはPythonで書けるdiscord.pyのcommandsフレームワークを使います。Botは参加者との対話形式で、Botのことをよく知らない方でも使える設計にします。
また、付与したいロールの条件に当てはまるかどうかを判断するために、参加者情報を保存しておくデータベースを用意する必要なのですが、これにはGoogleFormとGoogleSpreadsheet及びそれを動かすGASを使ってデータベースもどきとして利用します。SQLを使ったデータベースの方が簡単に実装できるのですが、大会運営の方々が従来よりGoogleSpreadsheetを使って運営を行なってきたこと、プログラミング(コーディング)に無縁であることから、GASを使った方法が今後作業を共有する上で直感的で短時間での理解ができると思い、GASを選びました。
具体的なDiscordBotとデータベース(GAS)の関係図は以下の通り↓
データベース
よく使う関数
このデータベースを作成する上で、よく使う関数をcommon.gs
にまとめます
//シートオブジェクト取得
function getSheet(name){
spreadsheet = SpreadsheetApp.getActiveSpreadsheet(); //スプレッドシート
sheet = spreadsheet.getSheetByName(name); //シート
return sheet;
}
//JSONデータを送信するときに使う変換関数
function outputJSON(data){
let payload = JSON.stringify(data);
var output = ContentService.createTextOutput();
output.setMimeType(ContentService.MimeType.JSON);
output.setContent(payload);
return output;
}
//現在日時を取得する
function currentTime(){
const date = formatDate(new Date(), 'YYYY/mm/DD HH:MM:SS');
return date
}
function formatDate(date, format) {
format = format.replace(/YYYY/, date.getFullYear());
format = format.replace(/mm/, date.getMonth() + 1);
format = format.replace(/DD/, date.getDate());
format = format.replace(/HH/, date.getHours());
format = format.replace(/MM/, date.getMinutes());
format = format.replace(/SS/, date.getSeconds());
return format;
}
//Discordに通知を送る際に使う関数
function sendDiscord(textMessage, webHookUrl) {
if(textMessage=="") return; //通知内容が空の場合は何もしない
var jsonData =
{
"content" : textMessage
};
var payload = JSON.stringify(jsonData);
var options =
{
"method" : "post",
"contentType" : "application/json",
"payload" : payload,
};
UrlFetchApp.fetch(webHookUrl, options);
}
SpreadsheetをDB化
GoogleSpreadsheetはExcelのような表計算ができるWebサービスですが、これをデータベースとして利用する記事があったのでこちらを使わせていただきました。
このコードをLIBRARY.gs
として保存します(主キーの部分等、少し使いやすいように修正を入れています)
GoogleFormから送信されたデータの整形
GoogleFormから送信されたデータは、フォームデータとしてスプレッドシートに保存されていきます。大会のフォームでは、チームの情報と参加者の情報を一緒に入力し送信されるので、このままだとカラムが多くなり、データの管理がしにくくなるため、二つのシートで管理します。
シート名 | 対象情報 |
---|---|
indiv-master | 参加者の情報 |
team-master | チームの情報 |
情報の整形
各情報は以下のように整形します
|名前|読み方|TwitterID|スプラ2ウデマエ|スプラ3ウデマエ|フレンドコード|
|チーム名|チームID|大会ID|ナンバリング|リーダー|メンバー1|メンバー2|メンバー3|意気込み|
チームIDの生成
チームを識別する際に、チーム名で判断するのでもいいのですが、チーム名がリーダー名+チーム
でチーム名が万が一にも被る可能性があるので、チームごとに固有のIDを発行するプログラムを書きます。
//チームIDを生成する関数
function generate_teamID() {
let sheet = getSheet("teamID"); //生成したIDを保存するシート
let initCell = sheet.getRange("A1"); //初期セル
//既に生成されたIDのリスト
var id_list;
if (initCell.isBlank()){
id_list = null;
}
else{
let lastRow = sheet.getLastRow();
id_list = sheet.getRange("A1:A"+lastRow).getValues().flat();
}
//生成したIDが、既に生成されたIDと重複しているかを確認
var count = 0
var id = null;
while (count < 1){
id = randomId();
check = checkDup(id_list, id);
if (check==false){
continue;
}
count+=1
}
sheet.appendRow([id])
return id;
}
//a-z,0-9から8桁の乱数を生成する関数
function randomId(){
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789'; //a-z,0-9
const list = [];
for (let i = 0; i < 8; i++) {
const num = Math.floor(chars.length * Math.random())
list.push(chars[num])
};
const id = list.join('');
return id;
}
//既に生成した乱数と重複しているか確認する関数
function checkDup(idList, newId){
if (idList==null){
return true;
}
for (const usedId of idList){
if (usedId==newId){
return false;
}
}
return true;
}
生成したチームIDが被らないように保存しておくシートが必要なので注意
大会情報の取得
一回限りの大会ではなく、何回も行っていく大会である場合、どの大会に参加していたのかという情報をチーム情報に加えることで、DiscordBotおよびデータベースを使い回すことができます。
|大会名|大会ID|開始日時|終了日時|
大会名と大会ID(任意で決める)、そして開始・終了日時はそれぞれ大会の受付期間を設定すれば、次回の大会も同じ受付フォーム、データベース、そしてDiscordBotを使って、コードを修正しなくても使えるように設計します
現在受付している大会の情報を取得できるプログラム↓
//受付期間中の大会情報を検索する関数
function whatTournament() {
let sheetName = 'tournament-master' //対象シート名
let sheet = getSheet(sheetName) //対象シート
let lastRow = sheet.getLastRow(); //最終行番号
let tournamentList = sheet.getRange("C2:C"+lastRow).getValues().flat(); //大会IDのリスト作成
var tournamentData = null; //該当大会のデータ返り値用
//大会IDリストから各大会のデータ確認
for (const tournament of tournamentList){
let data = readRecords(sheetName, "大会ID='"+tournament+"'", null,).flat(); //各大会の情報
let min = data[3]; //受付開始日時
let max = data[4]; //受付終了日時
let ct = new Date(currentTime()); //現在日時
let terms = max>=ct && min<=ct; //現在日時が受付期間中か判断
//termsがtrueの場合、【該当大会あり】として大会情報をseasonDataに入れて返す
if (terms == true){
tournamentData = data; //大会情報を返り値用変数に挿入
break; //for文を終了
}
else continue; //次の大会情報確認へ
};
return tournamentData; //該当大会情報を返す
}
フォーム送信時処理
大会フォームとスプレッドシートを紐づけ、GASにトリガーをつけて、application
を実行関数に設定し、フォームデータが送信されたら、参加者情報とチーム情報を分けます
//フォーム申請時の起動用関数
function application(e){
if (e.range.getSheet().getName() === 'team-log') team_application(e) //参加応募フォームへの送信時用
}
//参加応募フォーム送信時の関数
function team_application(e){
let indivMaster = 'indiv-master'; //ユーザ情報テーブル名
let teamMaster = 'team-master'; //チーム情報テーブル名
let listOfField = [
"リーダーの名前","リーダーの名前(ひらがな)","リーダーのスプラ2ウデマエ","リーダーのスプラ3ウデマエ","リーダーのフレンドコード","リーダーのTwitterID",
"1人目の名前","1人目の名前(ひらがな)","1人目のスプラ2ウデマエ","1人目のスプラ3ウデマエ","1人目のTwitterID",
"2人目の名前","2人目の名前(ひらがな)","2人目のスプラ2ウデマエ","2人目のスプラ3ウデマエ","2人目のTwitterID",
"3人目の名前","3人目の名前(ひらがな)","3人目のスプラ2ウデマエ","3人目のスプラ3ウデマエ","3人目のTwitterID",
"意気込み"
]; //フォームの各質問タイトルリスト
let rd = []; //フォームの回答データ挿入用空配列
for (const l of listOfField) rd.push(e.namedValues[l]); //フォームの回答データを挿入
let ct = currentTime(); //現在日時
//各ユーザ情報の連想配列関連
let leaderData = {"名前":rd[0], "読み方":rd[1], "スプラ2ウデマエ":rd[2], "スプラ3ウデマエ":rd[3], "フレンドコード":rd[4], "TwitterID":rd[5], "登録日時": ct, "最終更新日時": ct}; //リーダーのユーザ情報
let member1Data = {"名前":rd[6], "読み方":rd[7], "スプラ2ウデマエ":rd[8], "スプラ3ウデマエ":rd[9], "フレンドコード": "なし", "TwitterID":rd[10], "登録日時": ct, "最終更新日時": ct}; //メンバー1のユーザ情報
let member2Data = {"名前":rd[11], "読み方":rd[12], "スプラ2ウデマエ":rd[13], "スプラ3ウデマエ":rd[14], "フレンドコード": "なし", "TwitterID":rd[15], "登録日時": ct, "最終更新日時": ct}; //メンバー2のユーザ情報
let member3Data = {"名前":rd[16], "読み方":rd[17], "スプラ2ウデマエ":rd[18], "スプラ3ウデマエ":rd[19], "フレンドコード": "なし", "TwitterID":rd[20], "登録日時": ct, "最終更新日時": ct}; //メンバー3のユーザ情報
let indivDataList = [leaderData, member1Data, member2Data, member3Data]; //各ユーザ情報を配列に挿入
for (const d of indivDataList) insertRecord(indivMaster, d); //ユーザ情報をマスタに登録
//チーム情報の連想配列関連
let teamID = generate_teamID(); //チームIDを生成
let tournamentID = whatTournament()[2]; //大会情報を生成->大会IDを選択
let teamData = {
"チーム名": rd[0]+"チーム", "チームID": teamID, "大会ID": tournamentID,
"リーダー": rd[5], "メンバー1": rd[10], "メンバー2": rd[15], "メンバー3": rd[20],
"意気込み": rd[21], "登録日時": ct, "最終更新日時": ct
}; //チーム情報
insertRecord(teamMaster, teamData); //チーム情報をマスタに登録
}
listOfField
は、フォームの質問の名前のリストです。正しい質問名ではないとエラーが発生するので注意。
DiscordBotからのPOST処理
DiscordBotから送信されたPOST処理を書きます。送信されたパラメータで処理を分け、DiscordBotでのID照合時と、ログ記入に使用します。
function doPost(e) {
//リクエスト関連定義
let param = e.parameter.param;
let inputData = JSON.parse(e.postData.getDataAsString());
//データベース関連定義
let indivMaster = 'indiv-master';
let teamMaster = 'team-master';
let iconLog = 'icon-log';
//パラメータごとの処理分け
switch(param){
//入力されたTwitterIDを持ったユーザ情報確認
case "user_check":{
let userid = inputData[0]; //ユーザのTwitterID
let data = readRecords(indivMaster, "TwitterID='"+userid+"'", null,); //検索
let result = []; //結果用空配列
if (data!=null){
result.push(true)
}
else{
result.push(false)
}
output = outputJSON(result); //結果を変換
return output; //返却
}
//コマンド実行者が指定リーダーのチームに所属しているか確認
case "team_check":{
let authorid = inputData[0]; //コマンド実行者のTwitterID
let leaderid = inputData[1]; //指定リーダーのTwitterID
let data = readRecords(teamMaster, "リーダー='"+leaderid+"'", null,).flat(); //検索
let result = []; //結果用空配列
if (data!=null){
var count = 5; //メンバーのインデックス初期値
while (count < 9){
let id = data[count]; //チームメンバーのTwitterID
if (id == authorid) {
result.push(true); //trueを挿入
break;
}
count+=1;
}
if (result.length == 0) result.push(false); //結果配列の長さが0の場合、falseを結果として挿入
}
else{
result.push(false); //falseを挿入
}
output = outputJSON(result); //結果を変換
return output; //返却
}
//アイコン提出ログを記入する関数
case "icon_upload":{
let authorid = inputData[0]; //コマンド実行者のTwitterID
let authorData = readRecords(indivMaster, "TwitterID='"+authorid+"'", null,).flat(); //コマンド実行者情報
let authorName = authorData[1]; //コマンド実行者の名前
let tournamentID = whatTournament()[2]; //大会ID
let iconUrl = inputData[1]; //提出されたアイコンのURL
let ct = currentTime(); //現在日時
let data = {"名前": authorName, "TwitterID": authorid, "大会ID": tournamentID, "アイコンURL": iconUrl, "登録日時": ct, "最終更新日時": ct}; //アイコン提出ログ用連想配列
insertRecord(iconLog, data); //ログ記入
return;
}
}
}
DiscordBotからのGET処理
DiscordBotがロール付与をする際に、該当ロールを返すGET処理を作ります。
function doGet(e) {
let param = e.parameter.param;
switch(param){
case "hcsGiveRole":{
let authorid = e.parameter.table; //コマンド実行者のTwitterID
let leaderid = e.parameter.subjectID; //リーダーのTwitterID
let tournamentName = whatTournament()[1]; //大会名 ※大会ロール名が大会名と同一でなければいけないため注意
let teamNumbering = readRecords('team-master', "リーダー='"+leaderid+"'", null,).flat()[4]; //チームのナンバリング ※運営が手動でナンバリングを振る必要性があるので注意
var data;
if (authorid == leaderid){
data = {"大会ロール": tournamentName, "リーダーロール": "あり", "ナンバリングロール": teamNumbering};
}
else{
data = {"大会ロール": tournamentName, "リーダーロール": "なし", "ナンバリングロール": teamNumbering};
}
output = outputJSON(data);
return output;
}
}
}
DiscordBot
環境
Python 3.8.9
Lib
discord==1.7.3
Flask==2.2.2
discord.py
及びFlask
はpipでインストールしてください
ディレクトリ構成
以下の構成図はDiscordBotが完成した際のものです。このプログラムはかなり小規模なものなのでフォルダを細かく分けてませんが、気になる方はお好みで分けてください。ファイルの読み込みはこの構成図を前提として書いてますので、そちらも一緒に修正して書いてください
.
├─ settings
│ └─ appsettings.json
│ └─ discordsettings.json
├─ module
│ └─ json_module.py
│ └─ discord_module.py
├─ main.py
├─ event_listener.py
├─ feature.py
├─ database.py
├─ server.py
└─ log.py
JSONを開く関数
このプログラムではJSONからデータを持ってくることが多いので、JSONを簡単に開けるように関数化します
import json
def open_json(name):
with open(name, 'r', encoding='utf-8') as f:
data = json.load(f)
return data
よく使う埋め込みテキスト(Embed)を関数化
Discordには埋め込みテキスト(Embed)というBotのみが使える見た目のいいメッセージを作成することができます。
Botが送るメッセージの内容が見やすく・分かりやすく伝わるので、よく使うEmbedをテンプレート化します。
参考になるサイト: https://cog-creators.github.io/discord-embed-sandbox/
import discord
class custom_embed:
#通常
def default(title, content, color=0x0067C0):
embed=discord.Embed(title=title,description=content, color=color)
return embed
#完了
def success(content):
embed=discord.Embed(description=content,color=0x2CA02C)
embed.set_author(name='完了', icon_url='http://assets.stickpng.com/images/5aa78e207603fc558cffbf19.png')
return embed
#エラー
def error(content):
embed=discord.Embed(description=content,color=0xEC1A2E)
embed.set_author(name='エラー', icon_url='https://www.freeiconspng.com/thumbs/error-icon/error-icon-4.png')
return embed
#他はリポジトリ参照
Bot起動ファイル
DiscordBotを起動するためのコードを書きます。まずはBotに必要な設定をJSONファイルに入れます
{
"prefix": "!"
}
この設定ファイルにはプレフィックス(prefix)と呼ばれるコマンドとして認識するための文字列を設定します。もう一つ設定が必要なトークン
というものがありますが、ここに書くのはとても危険なのでやめましょう。
トークンは起動ファイルからOSの環境変数として取得するように書きます。以下、それを含めたBotの起動ファイルです
import os
import discord
from discord.ext import commands
#botの設定ファイルを開く
settings = open_json('discord-side/appsettings.json')
#botの設定
prefix = settings["prefix"] #設定ファイルから取得したprefix
token = os.getenv('DISCORD_BOT_TOKEN') #OSの環境変数から取得したトークン
intents = discord.Intents.all()
#botを作成
bot = commands.Bot(intents=intents, command_prefix=prefix)
#botを起動
bot.run(token)
Discordサーバーの設定の確認
Botを運用する上で、コマンドを実行するカテゴリやチャンネルなど、各種設定が有効なものかを確認します。これを行うことで、コマンドが実行された時に指定カテゴリ・チャンネルが存在しない等のエラーを未然に防ぐことができます。
Discordサーバー設定
各種IDの設定はJSONファイルに入れます
{
"server": 1010101010,
"admin": {
"category": 1010101010,
"channel": {
"log": 1010101010,
"icon": 1010101010,
}
},
"command": {
"category": 1010101010,
"frontdesk": "受付チャンネル名",
"channel": {
"command": 1010101010,
"log": 1010101010
}
},
"role": {
"to_use": 1010101010
}
}
値の部分に書かれている1010101010
は、各Discordのサーバーやカテゴリ、チャンネルのIDを入れる部分です。Botを導入する際にサーバーからIDを取得して、設定に当てはめてください。詳しい方法は DiscordBOTの作成 にて説明しています
設定確認
commandsフレームワークのCogという機能を使って、起動時に処理するイベントを別のファイルに書きます(視認性を良くするため)。
Cogについて参考になる記事: https://qiita.com/sizumita/items/c58170b72790df8ba417
以下を起動ファイルに追記します
bot.load_extension('event_listener') #引数はファイル名(拡張子は必要なし)
Cogを使うためのクラスを作ります。Cogを有効化するためには必ず一番下のsetup
を書かないと起動ファイルに書いたload_extension
でエラーが発生するので注意
import discord
from discord.ext import commands
class when_ready(commands.Cog):
def __init__(self, bot):
self.bot = bot
def setup(bot):
return bot.add_cog(when_ready(bot))
各種Discordオブジェクトを取得する方法↓
import discord
#サーバーの取得方法
#Discordではサーバーのことをguildといいます
guild = bot.get_guild(id=guild_id)
#カテゴリの取得方法
category = discord.utils.get(guild.categories, id=category_id)
#チャンネルの取得方法
channel = discord.utils.get(guild.channels, id=channel_id)
#ロールの取得方法
role = discord.utils.get(guild.roles, id=role_id)
各種Discordオブジェクトが無効だった場合、discord.utils.get
はNoneType
を返します。これを使って、discordsettings.json
で設定した各種IDが有効なものか確認する関数を書きます。これは作ったクラス内に書いてください
from module.json_module import open_json
#イベントを実行するためのデコレータをつける
#'on_ready'->起動時処理を行う関数名
@commands.Cog.listener(name='on_ready')
async def check_vaild_discord_settings(self):
try:
#Discord設定を開く
settings = open_json('settings/discordsettings.json')
#サーバーIDからサーバーが有効か確認
guild_id = settings["server"]
guild = self.bot.get_guild(guild_id)
if guild == None:
raise Exception(
f"サーバーID: {guild_id}\n"+
"エラー: 有効なIDではありません。設定し直してください"
)
#他の確認作業はリポジトリを参照
except Exception as e:
print(e)
await self.bot.close() #Botを終了
else:
print("Discord設定: 有効")
無効な設定が入っていた場合はBotが終了するように設計します。これにより、起動処理後にDiscord上で削除等しない限り、設定周りでエラーが発生することはありません。
ログ出力を関数化
コマンドを使った際に一括でログを送信できる関数を作ります。コンソール以外にも、Discord設定の確認で設定したチャンネルにもログを送信できるように、Cogを利用してチャンネルを指定します。
import discord
from discord.ext import commands
from module.json_module import open_json
from module.discord_module import custom_embed
class send_log(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.discord_settings = open_json('static/discord_settings.json')
async def error(self, content):
print(content)
guild_id = self.discord_settings["server"]
guild = self.bot.get_guild(guild_id)
admin_log_channel = discord.utils.get(guild.channels, id=self.discord_settings["admin"]["channel"]["log"])
command_log_channel = discord.utils.get(guild.channels, id=self.discord_settings["command"]["channel"]["log"])
await admin_log_channel.send(embed=custom_embed.error(content))
await command_log_channel.send(embed=custom_embed.error(content))
#他はリポジトリ参照
参加者との対話形式中に送信する細々としたメッセージは各関数実行中に書き、エラーや完了ログのような大きな意味を持つログはsend_log
を使います
アイコン提出/ロール付与プログラム
アイコン提出をしてからロールを付与するまでの機能を作ります。
Cogを使ってコマンド実行関数を作るので、Bot起動ファイルでコマンド実行関数を入れるファイルにExtensionを付与します。
bot.load_extension("feature")
コマンドを実行するためのクラスを定義。
import discord
from discord.ext import commands
from module.json_module import open_json
from module.discord_module import custom_embed
from log import send_log
class icon(commands.Cog):
def __init__(self, bot):
self.bot = bot
self.discord_settings = open_json(discordsettings.json)
self.send_log = send_log(bot)
def setup(bot):
return bot.add_cog(icon(bot))
Spreadsheetから情報を取得するための関数
ロールを付与する上で、コマンド実行者がデータベースに登録されているかを確認するために、GET・POST処理を行えるように関数を作ります
import os
import json
import requests
url = os.getenv('GAS_PROJECT_URL')+'?'
#DBへのPOST処理
async def post_db(param, data):
uurl = url + f'param={param}'
output = requests.post(uurl, data=json.dumps(data))
return output
#DBへのGET処理
async def get_db(param, table, subjectID):
payload = {'param': param, 'table': table, 'subjectID': subjectID}
output = requests.get(url, params=payload)
return output
url
はGASをWebアプリとしてデプロイした時に発行されるURLで、これを公開すると誰でもGASにアクセスができるのでOSの環境変数から取得するようにします
メッセージ待機機能
対話形式でプログラムを実現するために、Botがコマンド実行者の入力を待機し、入力内容を取得する機能が必要なので作ります
import asyncio
#ユーザからのメッセージを確認する関数
async def check_message(self, channel, author):
def check(m: discord.Message):
return m.channel == channel and m.author.id == author.id
try:
message = await self.bot.wait_for('message', check=check, timeout=60.0)
except asyncio.TimeoutError:
error = await channel.send(embed=custom_embed.error("待機時間内にメッセージが確認できませんでした"))
return None
else:
return message.content
これを使って、Embedでコマンド実行者が見やすい待機メッセージ関数を作ります
#ユーザからのメッセージを待機する関数
async def await_message(self, channel, author, title, content):
bot_msg = await channel.send(embed=custom_embed.default(title, content))
user_msg = await icon.check_message(self, channel, author)
return user_msg
受付チャンネルの作成
対話形式でBotのプログラムを実行する上で、コマンドを実行した場所に対話を書いていくと、コマンドを実行するチャンネルがごちゃごちゃになるので、対話を行う受付チャンネルを作成する関数を作ります
#受付チャンネルの作成
async def create_frontdesk(self, ctx, role, author):
try:
channelName = self.discord_settings["command"]["frontdesk"] #チャンネルID
categoryId = self.discord_settings["command"]["category"] #カテゴリID
channel = await ctx.guild.create_text_channel(channelName) #チャンネルを作成
category = discord.utils.get(ctx.guild.channels, id=categoryId) #カテゴリを取得
await channel.edit(category=category) #チャンネルを指定カテゴリへ移動
await channel.set_permissions(ctx.guild.default_role, read_messages=True, send_messages=False) #デフォルトロールの権限設定
await channel.set_permissions(role, read_messages=True, send_messages=False) #指定ロールの権限設定
await author.add_roles(role) #コマンド実行者にロール付与
except Exception as e:
await send_log.error()
print("エラー: 受付チャンネルを作成中にエラーが発生しました。エラー内容は以下の通り")
print(e)
return None
else:
return channel
コマンド実行者にのみ付与されるBot使用ロールでチャンネルに権限設定をすることで、作成された受付チャンネルでコマンド実行者以外が書き込めないようにすることができます
ロール付与までのフロー
1. コマンド実行者が送信したコマンドに画像が添付されているかどうかを確認
2. コマンド実行者のTwitterIDが有効なものかどうかを確認
3. リーダーのTwitterIDが有効なものかどうかを確認
4. コマンド実行者が指定リーダーのチームに所属しているかを確認
5. コマンド実行者にロールを付与
6. ログを出力
これらの項目をupload_checker
という関数にまとめます
画像添付確認
#ユーザから送信されたメッセージに画像が添付されているかの確認
try:
message = ctx.message #メッセージ
attachments = message.attachments #添付ファイルリスト
#添付ファイルリストが0の場合、コマンドをはじく
if len(attachments) == 0:
await channel.send(embed=custom_embed.error("画像が添付されていません。画像を添付したうえで再度コマンドの実行をお願いします"))
return
except Exception as e:
await send_log.error(
self,
f"<@{author.id}>さんの画像添付確認中に予期せぬエラーが発生しました。このメッセージが送信されたら運営までご相談ください\n"+
f"エラー内容: {e}"
)
return
コマンド実行者のTwitterID確認
#ユーザTwitterID確認
try:
#コマンド実行者のTwitterIDの入力
title_userid = "実行者のTwitterID"
content_userid = "このコマンドを実行しているユーザのTwitterIDを入力してください"
authorid = await icon.await_message(self, channel, author, title_userid, content_userid)
if authorid == None: return
#待機メッセージ
await channel.send(embed=custom_embed.waiting("有効なTwitterIDであるか確認中"))
#コマンド実行者のTwitterID登録確認
author_result = (await database.post_db('user_check', [authorid])).json()
if author_result[0] != True:
await channel.send(embed=custom_embed.error("正しいTwitterIDを入力してください"))
return
except Exception as e:
await send_log.error(
self,
f"<@{author.id}>さんのユーザTwitterID確認中に予期せぬエラーが発生しました。このメッセージが送信されたら運営までご相談ください\n"+
f"エラー内容: {e}"
)
return
else:
await channel.send(embed=custom_embed.success("ユーザのTwitterIDを確認"))
リーダーのTwitterID確認
#コマンド実行者が所属するチームのリーダーのTwitterID入力
try:
title_leaderid = "チームリーダーのTwitterID"
content_leaderid = "このコマンドを実行しているユーザが所属するチームのリーダーのTwitterIDを入力してください"
leaderid = await icon.await_message(self, channel, author, title_leaderid, content_leaderid)
if leaderid == None: return
#待機メッセージ
await channel.send(embed=custom_embed.waiting("有効なTwitterIDであるか確認中"))
#チームリーダーのTwitterID登録確認
leader_result = (await database.post_db('user_check', [authorid])).json()
if leader_result[0] != True:
await channel.send(embed=custom_embed.error("正しいTwitterIDを入力してください"))
return
except Exception as e:
await send_log.error(
self,
f"<@{author.id}>さんのリーダーTwitterID確認中に予期せぬエラーが発生しました。このメッセージが送信されたら運営までご相談ください\n"+
f"エラー内容: {e}"
)
return
else:
await channel.send(embed=custom_embed.success("リーダーのTwitterIDを確認"))
チーム所属確認
try:
#待機メッセージ
await channel.send(embed=custom_embed.waiting("コマンド実行者が指定リーダーのチームに所属しているか確認中"))
#コマンド実行者が指定リーダーのチームに所属しているか確認
idList = [authorid, leaderid]
team_result = (await database.post_db('team_check', idList)).json()
if team_result[0] != True:
await channel.send(embed=custom_embed.error("指定したリーダーのチームに所属していません"))
return
except Exception as e:
await send_log.error(
self,
f"<@{author.id}>さんの所属チーム確認中に予期せぬエラーが発生しました。このメッセージが送信されたら運営までご相談ください\n"+
f"エラー内容: {e}"
)
return
else:
await channel.send(embed=custom_embed.success("コマンド実行者がリーダーのチームに所属していることを確認"))
ロール付与
#ロール付与
try:
roleInfo = (await database.get_db('hcsGiveRole', authorid, leaderid)).json()
roleNameList = []
roleNameList.append(roleInfo["大会ロール"])
if roleInfo["リーダーロール"] == "あり":
roleNameList.append("チームリーダー")
if roleInfo["ナンバリングロール"] != "":
roleNameList.append("Team"+str(roleInfo["ナンバリングロール"]))
else:
admin_channel = discord.utils.get(ctx.guild.channels, id=self.discord_settings["admin"]["channel"]["log"])
await admin_channel.send(
embed=custom_embed.error(f"<@{author.id}>さんにチームナンバリングロールが付与できませんでした。手動での追加をお願いします")
)
for roleName in roleNameList:
role = discord.utils.get(ctx.guild.roles, name=roleName)
await author.add_roles(role)
log_channel = discord.utils.get(ctx.guild.roles, id=self.discord_settings["command"]["channel"]["log"])
await log_channel.send(embed=custom_embed.success(f"<@{author.id}>さんにロールを付与しました"))
except Exception as e:
await send_log.error(
self,
f"<@{author.id}>さんのロール付与中に予期せぬエラーが発生しました。このメッセージが送信されたら運営までご相談ください\n"+
f"エラー内容: {e}"
)
return
アイコンログ記入
#アイコン提出ログ記入
try:
iconUrl = attachments[0].url
iconData = [authorid, iconUrl]
await database.post_db('icon_upload', iconData)
icon_channel = discord.utils.get(ctx.guild.channels, id=self.discord_settings["admin"]["channel"]["icon"])
await icon_channel.send(f"<@{author.id}>のアイコン↓")
await icon_channel.send(attachments[0].url)
except Exception as e:
await send_log.error(
self,
f"<@{author.id}>さんのアイコンログ記入中に予期せぬエラーが発生しました。このメッセージが送信されたら運営までご相談ください\n"+
f"エラー内容: {e}"
)
return
コマンドで実行できるように
各項目を組み合わせて、コマンドで実行できるように一つの関数にします。関数はCogが入っているクラスに入れます
#アイコン提出コマンド
@commands.command(name='upload')
async def upload_command(self, ctx):
try:
author = ctx.author #コマンド実行者
role = discord.utils.get(ctx.guild.roles, id=self.discord_settings["role"]["to_use"]) #対象ロール
frontdesk = await icon.create_frontdesk(self, ctx, role, author) #受付チャンネル
if frontdesk == None:
raise Exception
await frontdesk.send(f"<@{author.id}>") #コマンド実行者にメンション
await icon.upload_checker(self, ctx, frontdesk, author) #アイコン提出確認
except Exception as e:
print("エラー: アイコン提出コマンドを実行中にエラーが発生しました。エラー内容は以下の通り")
print(e)
finally:
await frontdesk.send(embed=custom_embed.default("終了", "10秒後にこのチャンネルは削除されます"))
await asyncio.sleep(10)
await author.remove_roles(role)
await frontdesk.delete()
以上でDiscordBotのコマンドを使ってアイコン提出->ロール付与のプログラムが使えるようになりました。
ホスティングについて
Botを常時起動させるにはどこかのサーバーに置いておく必要がありますが、私はお金をかけたくなかったので、Replitというオンライン統合開発環境を使ってホスティングをしました。そのためにいくつかやらなければいけないことがあるので、もし無料でホスティングを行いたい場合はリポジトリのserver.py
をコピーし、起動ファイルであるmain.py
の、botを起動する部分を一部変更してください。Replitでは、起動してから少し経つと自動でプログラムを終了してしまうので、FlaskとUptimeRobotを使って、Replitのページを閉じてもプログラムを起動し続けています。
もし自サーバーやローカルで動かせるのであれば、ここまで書いた物だけで動きますのでそのままデプロイしましょう。UptimeRobotの設定等は実装方法でご覧ください
実装方法
Discordサーバーの設定
Discordの開発者モード
Discordには開発者モードというのが用意されており、これを何に使うのかというと、DiscordBotでサーバーやカテゴリを設定するためのIDを取得するために使います。まずはこの開発者モードを有効にしてください。
カテゴリ・チャンネル作成
Discordサーバーの作成方法は省きます。
Botを使いたいサーバーに、コマンドと運営カテゴリを作り、以下のチャンネルを指定カテゴリに配置しましょう
カテゴリ | チャンネル |
---|---|
コマンド | コマンド実行用 |
コマンド | 通知用 |
運営 | アイコン送信用 |
運営 | 通知用 |
ロール作成
ロールの作成方法は省きます。
自動付与したいロールを作成しましょう。このプログラムをそのまま使う場合は、以下の通りにロール名を指定してください
ロール | ロール名の指定方法 |
---|---|
大会ロール | Spreadsheetのtournament-masterで指定した大会名 |
リーダーロール | リーダー |
チームロール |
Team + Spreadsheetのteam-masterで指定した数字 |
DiscordBotの設定
まずはDiscordBotを作成します。
上記リンクを開いて、画面右上のNew Application
からアプリケーションを作成します。
アプリケーション名を求められますが、ここの名前はなんでもいいです。
アプリケーションを開いたらこの画面になります。この画面ではアプリケーションの情報を変更することができます。アプリケーションの名称変更だったり、説明文を追加することができます
Botの作成に移ります。左メニューからBot
を選択して、Add Bot
を押します
Botが作成できると、Botの編集画面が表示されます。ここのReset Token
を押して、Bot起動に必要なトークンを取得します。取得したトークンはメモ帳かどこかに仮保存しておいてください
次はBotの設定を変更します。スクロールするとPrevileged Gateway Intents
という見出しの部分が見えます。ここの設定PRESENCE INTENT
SERVER MEMBERS INTENT
MESSAGE CONTENT INTENT
の設定を全てオンにします。(初期設定では全てオフになっているので、画像のようにします)。この設定がないと、Discord上のサーバー情報やユーザ情報を読み取ることができないので注意。
最後に作成したBotを招待するためのURLを発行します。左メニューのOAuth2
のURL Generator
に遷移します。画面の通り、bot
を選択し、下にスクロールします。
ここではBotの権限設定をすることができます。招待をする際に、Botにどのような権限を付与したいかを表示し、招待者が確認できるようになっています。本来であればかなり危険ですが、私は自分でBotのコードを書いているので、面倒な権限設定を飛ばすためにAdministrator
=管理者を付与しています。この権限がついていると、サーバー作成者と同等の権限でほぼ全てのことができますので注意
権限設定ができたらまたスクロールすると、URLができていますので、これをコピーして自分のサーバーに招待しましょう。
DiscordサーバーにBotが招待できると、Discordサーバーに自動的にBotの名前のロールが作られています。そのロールをDiscordサーバーの設定で作成したロールよりも高位に置いてください。Botのロールが付与したいロールよりも下位にあると、ロールを付与するところでエラーが発生します。
データベース(Spreadsheet)の設定
Spreadsheet及びGASプロジェクト作成
データベースとして利用するSpreadsheetを作成します。下記リンクを開いて、ファイル
タブからコピーを作成しましょう。各シートやGASプロジェクトを全てコピーすることができるので、そのまま使うことができます
SpreadsheetIDの設定
Spreadsheetを開き、拡張機能
タブを開くとApps Script
というのが見えると思うので、それを開きます。開いたらGASプロジェクトの中身が見えます。一番最初の画面がLIBRARY.gs
というファイル名の編集画面になっているはずです。そのファイルの一番上のsheetID
の部分を、SpreadsheetのID部分を追記します。
SpreadsheetのIDの取得方法↓
GoogleFormとの連携
チーム及び参加者情報の収集に使うフォームと連携して、回答先のシートはteam-log
という名前に設定してください。ここにフォームデータが挿入されていきます。また、フォームの各質問のタイトルは必ずapplication.gs
内のlistOfField
と同じものにしてください。タイトル名が違うとエラーが発生します
トリガーの追加
左メニューの時計のアイコンに遷移します。左下のトリガーを追加
を押します。
押すとこのようなトリガーの設定画面が出てきます。トリガーの設定を画像のように設定してください。
これでGoogleFormからのフォームデータを、参加者情報とチーム情報に分ける関数を動かすことができます。
Webアプリをデプロイ
GASプロジェクトを開いている状態であれば、プロジェクト名の隣にデプロイ
というボタンがあります。これをクリックします。
デプロイメニューが出てくるので、新しいデプロイ
を選択
デプロイ設定が出てくるので、説明文を追加してデプロイします
アクセス承認を求められるので、指示に従って全て承認します
承認できたら、デプロイ情報が記載されます。ここのウェブアプリ
下にあるURLを保存しておいてください。このURLを使うことで、DiscordBotからのPOST・GET処理を行うことができます。
DiscordBotのホスティング
ここではReplitとUptimeRobotを使ったホスティング方法を書きます。もし使わない場合は、DiscordBotのコードがあるリポジトリを直接ダウンロードして、設定を編集してください
Replitの設定
サインアップ
Replitを開きます。開いたらサインアップしてください。
GitHubからリポジトリをコピー
サインアップが終わるとホーム画面に移ります。この画面の右上+ Create
を押してください
Replの設定画面が開きます。この右上Import from GitHub
を押してください
GitHub URLに、下のGitHubリポジトリのURLを画像のように入れてください。終わったら右下Import from GitHub
を押します(リポジトリにDiscordBotを使うためのプログラムが全て入っています)
DiscordBotのリポジトリ↓
プログラムの実行設定
Replが作成できるとこのような画面が出ます。この右上.replit
のウィンドウで、どのファイルを実行するのかを設定します。ここではmain.py
を選択してください
設定できたらDone
を押してウィンドウを閉じましょう
環境変数の設定
DiscordBotを起動するためのトークンと、GASプロジェクトのWebアプリのURLをここで設定します。画面左下のTools
ウィンドウのSecrets
を選択します
右のウィンドウにこのような画面が出てくるまで進めてください。
key | value |
---|---|
DISCORD_BOT_TOKEN | Discord開発者ポータルで保存したトークン |
GAS_PROJECT_URL | GASのWebアプリのURL |
表を参考にして、keyとvalueに入力してください入れ終わったらAdd new secret
で変数を追加します。
Discordの設定を反映
左メニューのFiles
からDiscord設定が入ってるdiscordsettings.json
を開きます
ここでDiscordサーバーで保存した各種ID等を設定します。以下の表を参考にしてください
キー | 設定するID |
---|---|
["server"] | サーバー |
["admin"]["category"] | 運営カテゴリ |
["admin"]["channel"]["log"] | 運営通知用チャンネル |
["admin"]["channel"]["icon"] | アイコン送信用チャンネル |
["command"]["frontdesk"] | コマンド実行時受付チャンネル(これだけ名前) |
["command"]["category"] | コマンドカテゴリ |
["command"]["channel"]["command"] | コマンド実行チャンネル |
["command"]["channel"]["log"] | コマンド実行者通知用チャンネル |
["role"]["to_use"] | コマンド使用者用ロール |
必要なライブラリをインストール
Shell
で、DiscordBotに必要なライブラリをインストールします。以下を入力して実行します
$ pip install -r requirements.txt
DiscordBotを起動
画面一番上の緑色のボタンRun
を押して、Webview
ウィンドウが画像のようになっていればBotの起動完了です
UptimeRobotの設定
最後に、Replitのプロジェクトを常時起動させるための設定をします。Replitはあくまでもサーバーではなく、開発環境なのでページを閉じるとプログラムが停止します。これをUptimeRobotというサービスを使って、常時起動させるようにします。
登録
UptimeRobotを開いて、右上Register for FREE
からアカウント登録をします。
モニターを設定
左上+ Add New Monitor
からモニターを追加します(画像ではすでに1個ありますが、これは自分の使っているモニターなので初期状態では何も記載されていません)
モニターの設定画面が開きます。開いたらMonitor Type
からHTTP(s)
を選択します。選択すると、画像のような設定が出てくるので、各項目の設定を行なってください。
名前 | 内容 |
---|---|
Friendly Name | モニターの名前 |
URL | ReplitでBotを起動したときに出てきたWebviewのURL(モザイクがかかっている部分) |
Monitor Interval | どのくらいの間隔でアクセスをするか(5分でOK) |
Monitor Timeout | タイムアウト設定 |
HTTP Method | どのリクエストメソッドを使ってアクセスをするか(HeadでOK) |
設定ができたらCreate Monitor
でモニター作成完了です。これでDiscordBotを常時起動させることができます。(Replitの仕様上、UptimeRobotでアクセスしていてもプログラムが停止することがあります。私は無料でできるだけいいと思って目を瞑っていますが、ちゃんと常時起動したい場合はサーバーなり使ってやってください)
総括
かなり長くなったのですが、初めて自分の作ったプログラムを記事に書けてとても満足です。雑に説明した部分とかあるので、次は詳細に分かりやすくできたらいいなと思います。
もし、ここで書いたものやGitHubにあげたものでエラーが発生した場合は、質問いただけると嬉しいです。完璧なものを作った自信はないので、改善のためによろしくお願いします。