概要
Go言語 (discordgo)を利用してのBot開発
Discord Botを作成できるライブラリは様々な言語に存在するが、今回はgoで作成してみる
(goの練習になると思うから)
今回のリポジトリ
※DiscordにおけるAppとBotの違いがあまり分からないので、本記事では全てBotと表記します
Botの作成
まずはDiscordのデベロッパーサイトにアクセス (たぶん英語でしか表示できません)
Discord Developer Portal — API Docs for Bots and Developers
Discordアカウントにログインしたのち、
画面左上のApplicationsを押すと、自分のBot一覧が表示されます (初めての場合は何も表示されません)
画面右上の New Application を押すと、Bot作成画面が出てくるので作成するBotの名前を入力します
名前入力と利用規約に承諾し Create ボタンを押すと、Botの設定画面に遷移します
ここではBotの様々な設定が出来ますが、まず初めにやることは Botのtokenを保存する事です
画面左側のBotタブ選択し、そこから Reset Token ボタンを押してtokenを生成します
(多分Create Tokenとかいうボタンは無くて、最初からReset Tokenで生成すると思う)
ResetTokenボタンの上にも英語で書いてありますが、tokenは作成時のみ表示する事ができ
それ以降はまたリセットしないと表示できません
※tokenはBotの操作に関わる重要な物です。外部へ流出した場合には早急にリセットしましょう
次に同じタブ少し下にスクロールし、 Privileged Gateway Intents の3項目を有効化します
ここで許可した内容についての詳細は各自で翻訳お願いします
軽く説明するとBotへのサーバーのイベントやメッセージの読み取り許可です
最後に Installationタブから Install Linkに表示されているURLを実行すると自分が管理権限を持っているサーバーへBotを招待できます
これでサーバー設定の アプリ → 連携サービス を選択するとBotが追加されているはずです!
Botを動かす
この章ではdiscordgoというライブラリを使って、Botを作成していきます
discord/session.go でセッションのメソッドを定義しています
ここで使用するトークンはBot作成時にコピーしたトークンです
セッションの作成自体は
dg, err := discordgo.New("Bot " + token)
この部分です
package discord
import (
"github.com/bwmarrin/discordgo"
"log"
)
// SessionManager Discordセッションを管理するインターフェース
type SessionManager interface {
InitializeSession(token string) *discordgo.Session
}
// DiscordSessionManager Discordセッションを管理する構造体
type DiscordSessionManager struct{}
// InitializeSession Discordセッションを初期化する
func (d *DiscordSessionManager) InitializeSession(token string) *discordgo.Session {
dg, err := discordgo.New("Bot " + token)
if err != nil {
log.Fatalf("Discordセッションの作成に失敗: %v", err)
}
return dg
}
main.go内で先ほど作成したセッション作成メソッドを使い、セッションをオープンしています
トークンは環境変数から取り出すことで、コードに直で書かないようにしています
そしてオープンしたセッションを停止入力されるまで待機し続けます
package main
import (
"DiscordBot/discord"
"github.com/bwmarrin/discordgo"
"log"
"os"
"os/signal"
"syscall"
)
var (
GuildID string
dgs *discordgo.Session
)
func main() {
sessionManager := &discord.DiscordSessionManager{}
dgs = sessionManager.InitializeSession(os.Getenv("DISCORD_BOT_TOKEN"))
// Botに権限を付与している
dgs.Identify.Intents = discordgo.IntentsGuildMessages | discordgo.IntentsGuilds | discordgo.IntentsGuildMembers | discordgo.IntentsAll | discordgo.PermissionSendMessages
if err := dgs.Open(); err != nil {
log.Fatalf("Discordセッションのオープンに失敗: %v", err)
}
defer dgs.Close()
log.Println("ボットが起動しました。Ctrl+Cで終了します。")
waitForExitSignal()
}
// waitForExitSignalは終了シグナルを待機してボットを安全にシャットダウンする
func waitForExitSignal() {
sc := make(chan os.Signal, 1)
signal.Notify(sc, syscall.SIGINT, syscall.SIGTERM, os.Interrupt, os.Kill)
<-sc
}
コマンドを作成する
この章ではスラッシュを用いたコマンドを作成していきます
コマンド作成には大きく分けて3つの処理が必要で、
- コマンドの名前や引数、説明などの設定
- コマンドを受け取った際の処理
- セッションへのコマンドの登録
これらの処理を行うことでコマンドを作成できます
このスクリプトでは上記の3の処理を行っています
// createCommandsはDiscordのコマンドを登録する
func createCommands() {
add := commands.CreateAddScheduleCommand{}
show := &commands.CreateShowSchedulesCommand{}
remove := &commands.CreateRemoveScheduleCommand{}
for _, cmd := range add.CreateCommand() {
_, err := dgs.ApplicationCommandCreate(dgs.State.User.ID, GuildID, cmd)
if err != nil {
log.Fatalf("コマンド登録失敗: %v", err)
}
}
for _, cmd := range show.CreateCommand() {
_, err := dgs.ApplicationCommandCreate(dgs.State.User.ID, GuildID, cmd)
if err != nil {
log.Fatalf("コマンド登録失敗: %v", err)
}
}
for _, cmd := range remove.CreateCommand() {
_, err := dgs.ApplicationCommandCreate(dgs.State.User.ID, GuildID, cmd)
if err != nil {
log.Fatalf("コマンド登録失敗: %v", err)
}
}
}
このスクリプトではコマンドの受け取りを行っています
今回はswitch文でコマンドの名前で判別しています
// onInteractionCreateはDiscordからのインタラクションイベントを処理する
func onInteractionCreate(s *discordgo.Session, i *discordgo.InteractionCreate) {
var cmd commands.Command
switch i.ApplicationCommandData().Name {
case "add-schedule":
cmd = &commands.AddScheduleCommand{}
case "show-schedules":
cmd = &commands.ShowSchedulesCommand{}
case "remove-schedule":
cmd = &commands.RemoveScheduleCommand{}
}
if cmd != nil {
cmd.Execute(s, i)
}
}
このスクリプトでは上記1、2の処理を行っています
package commands
import (
"DiscordBot/scheduler"
"DiscordBot/utils"
"fmt"
"github.com/ahmetb/go-linq"
"github.com/bwmarrin/discordgo"
"log"
"strconv"
"strings"
"time"
)
var (
JobDataSlice []utils.JobData
Ns = scheduler.NewGoCronScheduler(time.FixedZone("Asia/Tokyo", 9*60*60))
jsonWriter = &utils.FileJSONWriter{}
weekParseData = [7]string{"日曜日", "月曜日", "火曜日", "水曜日", "木曜日", "金曜日", "土曜日"}
)
// Command コマンドを処理するインターフェース
type Command interface {
Execute(s *discordgo.Session, i *discordgo.InteractionCreate)
}
// CommandFactory コマンドを生成するインターフェース
type CommandFactory interface {
CreateCommand() []*discordgo.ApplicationCommand
}
// CreateAddScheduleCommand スケジュールを追加するコマンドを生成するファクトリ
type CreateAddScheduleCommand struct{}
func (c *CreateAddScheduleCommand) CreateCommand() []*discordgo.ApplicationCommand {
dc := []*discordgo.ApplicationCommand{
{
Name: "add-schedule",
Description: "リマインドしたいスケジュールを追加",
Options: []*discordgo.ApplicationCommandOption{
{
Type: discordgo.ApplicationCommandOptionString,
Name: "team",
Description: "所属するチームを選択",
Required: true,
},
{
Type: discordgo.ApplicationCommandOptionInteger,
Name: "day_of_week",
Description: "リマインドする曜日 (0: 日曜日, 1: 月曜日, 2: 火曜日, 3: 水曜日, 4: 木曜日, 5: 金曜日, 6: 土曜日)",
Required: true,
},
{
Type: discordgo.ApplicationCommandOptionInteger,
Name: "時",
Description: "0~23",
Required: true,
},
{
Type: discordgo.ApplicationCommandOptionInteger,
Name: "分",
Description: "0~59",
Required: true,
},
{
Type: discordgo.ApplicationCommandOptionRole,
Name: "role",
Description: "リマインドする役職",
Required: true,
},
},
},
}
return dc
}
// AddScheduleCommand スケジュールを追加するコマンド
type AddScheduleCommand struct{}
// コマンドが実行された際の処理を書く
func (c *AddScheduleCommand) Execute(s *discordgo.Session, i *discordgo.InteractionCreate) {
team := strings.ToLower(i.ApplicationCommandData().Options[0].StringValue())
week := i.ApplicationCommandData().Options[1].IntValue()
hour := i.ApplicationCommandData().Options[2].IntValue()
minute := i.ApplicationCommandData().Options[3].IntValue()
role := i.ApplicationCommandData().Options[4].RoleValue(s, i.GuildID)
cronText := fmt.Sprintf("%d %d * * %d", minute, hour, week)
jobData := utils.JobData{Team: team, Cron: cronText, Role: role.ID}
JobDataSlice = append(JobDataSlice, jobData)
jsonWriter.WriteJSON("jobData.json", JobDataSlice)
Ns.RegisterJob(cronText, scheduler.SendRemindMessage, team, role.ID, s)
response := fmt.Sprintf("リマインドスケジュールを追加しました (Number: %d)\nチーム: %s\n曜日: %s\n時間: %d時%d分\n役職: %s", len(JobDataSlice), team, weekParseData[week], hour, minute, role.Name)
if err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: &discordgo.InteractionResponseData{Content: response},
}); err != nil {
log.Fatalf("コマンド実行失敗: %v", err)
}
log.Println(response)
}
リマインド機能をつくる
gocron というGo言語でcronのような形式でリマインドを作成出来るライブラリを使用する
-
そもそもcronとは?
Linux系のOSに搭載されているリマインダー機能
ジョブという形式で様々な動作を定期実行できる機能
分 時 日 月 曜日 <実行コマンド>
この形式で作成する
例: 0 15 * * * echo "hello.”
この他にも様々な指定方法があるが、割愛
package utils
// JobData は、チーム、cronスケジュール、および役職を持つジョブの構造体
type JobData struct {
Team string `json:"team"`
Cron string `json:"cron"`
Role string `json:"role"`
}
package scheduler
import (
"DiscordBot/utils"
"fmt"
"github.com/bwmarrin/discordgo"
"github.com/go-co-op/gocron/v2"
"log"
"os"
"time"
)
var channelId = map[string]string{}
// init は環境変数を読み込む
func init() {
envLoader := &utils.DotenvLoader{}
envLoader.LoadEnv("channel.env")
channelId = map[string]string{
"a": os.Getenv("TEAM_A"),
"b": os.Getenv("TEAM_B"),
"c": os.Getenv("TEAM_C"),
"d": os.Getenv("TEAM_D"),
"e": os.Getenv("TEAM_E"),
}
}
// Scheduler スケジューラを管理するインターフェース
type Scheduler interface {
RegisterJob(cronExpr string, jobFunc interface{}, params ...interface{})
Start()
Jobs() []gocron.Job
}
// GoCronScheduler gocronを使用したスケジューラの実装
type GoCronScheduler struct {
scheduler gocron.Scheduler
err error
}
// NewGoCronScheduler GoCronSchedulerのインスタンスを作成
func NewGoCronScheduler(location *time.Location) *GoCronScheduler {
s, err := gocron.NewScheduler(gocron.WithLocation(location))
return &GoCronScheduler{
scheduler: s,
err: err,
}
}
// RegisterJob ジョブをスケジューラに登録
func (s *GoCronScheduler) RegisterJob(cronExpr string, jobFunc interface{}, params ...interface{}) {
if _, err := s.scheduler.NewJob(
gocron.CronJob(cronExpr, false),
gocron.NewTask(jobFunc, params...),
); err != nil {
log.Fatalf("ジョブ登録失敗: %v", err)
}
}
// Start スケジューラを開始
func (s *GoCronScheduler) Start() {
s.scheduler.Start()
}
// Jobs 登録されているジョブを返す
func (s *GoCronScheduler) Jobs() []gocron.Job {
return s.scheduler.Jobs()
}
// RemoveJob ジョブをスケジューラから削除
func (s *GoCronScheduler) RemoveJob(jobID int) {
err := s.scheduler.RemoveJob(s.Jobs()[jobID].ID())
if err != nil {
log.Fatalf("ジョブ削除失敗: %v", err)
}
}
// SendRemindMessage リマインドメッセージを送信する関数
func SendRemindMessage(team string, roleID string, dgs *discordgo.Session) {
txt := fmt.Sprintf("<@&%s>\nMTGリマインドです~", roleID)
_, err := dgs.ChannelMessageSend(channelId[team], txt)
if err != nil {
log.Fatalf("メッセージ送信失敗: %v", err)
}
}
スケジュールの保存
JSON形式でローカルに保存
ローカルの.jsonファイルへの入出力はos ライブラリを使用する
読み込んだデータをスケジュールへ登録している
// initializeScheduleはスケジューラを初期化する
func initializeSchedule() {
for _, jobData := range commands.JobDataSlice {
s.RegisterJob(jobData.Cron, scheduler.SendRemindMessage, jobData.Team, jobData.Role, dgs)
}
s.Start()
}
スケジュールデータをjson形式でローカルへ書き込み、読み込みを行っている
package utils
import (
"encoding/json"
"fmt"
"log"
"os"
)
// JSONWriter JSONデータを書き込むインターフェース
type JSONWriter interface {
WriteJSON(filename string, data interface{})
}
// FileJSONWriter ファイルにJSONデータを書き込む構造体
type FileJSONWriter struct{}
// WriteJSON JSONデータをファイルに書き込む
func (w *FileJSONWriter) WriteJSON(filename string, data interface{}) {
f, err := os.Create(filename)
if err != nil {
log.Fatalf("ファイル取得失敗: %v", err)
}
defer f.Close()
output, err := json.MarshalIndent(data, "", "\t\t")
if err != nil {
log.Fatalf("JSONエンコード失敗: %v", err)
}
if _, err := f.Write(output); err != nil {
log.Fatalf("JSON書き込み失敗: %v", err)
}
}
// JSONReader JSONデータを読み込むインターフェース
type JSONReader interface {
ReadJSON(filename string) []JobData
}
// FileJSONReader ファイルからJSONデータを読み込む構造体
type FileJSONReader struct{}
// ReadJSON JSONデータをファイルから読み込む
func (r *FileJSONReader) ReadJSON(filename string) []JobData {
f, err := os.Open(filename)
if err != nil {
log.Fatalf("ファイル取得失敗: %v", err)
}
defer f.Close()
var data []JobData
decoder := json.NewDecoder(f)
if err := decoder.Decode(&data); err != nil {
log.Fatalf("JSONデコード失敗: %v", err)
}
fmt.Println(data)
return data
}
環境変数を読み込む
ローカルの.envファイルから要素を読み込み、環境変数へ登録する
読み込みには Joho/ godotenvというライブラリを利用する
// initializeEnvは環境変数の読み込みとJSONファイルの読み込みを行う
func initializeEnv() {
envLoader := &utils.DotenvLoader{}
envLoader.LoadEnv("bot.env")
envLoader.LoadEnv("channel.env")
GuildID = os.Getenv("DISCORD_GUILD_ID")
commands.JobDataSlice = append(reader.ReadJSON("jobData.json"))
}
package utils
import (
"github.com/joho/godotenv"
"log"
)
// EnvLoader 環境変数を読み込むインターフェース
type EnvLoader interface {
LoadEnv(filename string)
}
// DotenvLoader .envファイルを読み込む構造体
type DotenvLoader struct{}
// LoadEnv .envファイルを読み込み、環境変数に設定する
func (d *DotenvLoader) LoadEnv(filename string) {
err := godotenv.Load(filename)
if err != nil {
log.Fatal(".envファイルの読み込みに失敗しました:", err)
}
}
ロギング (slog)
log パッケージ単体では、FatalやPanicなどで例外処理を行うが、
問題の深刻度によってログの種類を変えたり、そのまま処理を続けるために標準パッケージの log / slog を使用する
通常のlog
log package - log - Go Packages
log / slog
slog package - log/slog - Go Packages
func logs() {
// logパッケージのログ例
log.Fatal("log.Fatal")
log.Panic("log.Panic")
log.Println("log.Println")
// slogパッケージのログ例
slog.Info("slog.Info")
slog.Warn("slog.Warn")
slog.Error("slog.Error")
slog.Debug("slog.Debug")
}
ソースコード・参考資料
Discordgo
discordgo package - github.com/bwmarrin/discordgo - Go Packages
gocron
slog
Discord Bot 仕様