Go言語を使ってリマインダーDiscordBotを作成する

# 概要

Go言語 (discordgo)を利用してのBot開発

Discord Botを作成できるライブラリは様々な言語に存在するが、今回はgoで作成してみる

(goの練習になると思うから)

今回のリポジトリ

https://github.com/RyuichiroYoshida/DiscordBot

※DiscordにおけるAppとBotの違いがあまり分からないので、本記事では全てBotと表記します
    
# Botの作成

まずはDiscordのデベロッパーサイトにアクセス (たぶん英語でしか表示できません)

[Discord Developer Portal — API Docs for Bots and Developers](https://discord.com/developers/docs/intro)

Discordアカウントにログインしたのち、

画面左上のApplicationsを押すと、自分のBot一覧が表示されます (初めての場合は何も表示されません)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3551653/f9d921f9-e773-e37f-04e0-776a1b95a17a.png)

画面右上の **New Application** を押すと、Bot作成画面が出てくるので作成するBotの名前を入力します

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3551653/52bd5c14-eb13-a1b1-b5d0-49ef3f10e3fc.png)

名前入力と利用規約に承諾し **Create** ボタンを押すと、Botの設定画面に遷移します

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3551653/ae8b9d78-41c9-6f20-a28d-ecd9ad6bc2ef.png)

ここではBotの様々な設定が出来ますが、まず初めにやることは **Botのtokenを保存する事です**

画面左側のBotタブ選択し、そこから **Reset Token** ボタンを押してtokenを生成します

(多分Create Tokenとかいうボタンは無くて、最初からReset Tokenで生成すると思う)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3551653/5e577793-94e7-6e4d-2f3b-76f251cf6649.png)

ResetTokenボタンの上にも英語で書いてありますが、tokenは作成時のみ表示する事ができ

それ以降はまたリセットしないと表示できません

※tokenはBotの操作に関わる重要な物です。外部へ流出した場合には早急にリセットしましょう

次に同じタブ少し下にスクロールし、 **Privileged Gateway Intents** の3項目を有効化します

ここで許可した内容についての詳細は各自で翻訳お願いします

軽く説明するとBotへのサーバーのイベントやメッセージの読み取り許可です

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3551653/c95fe667-9587-eb35-3ace-fe47a2b26847.png)

最後に Installationタブから Install Linkに表示されているURLを実行すると自分が管理権限を持っているサーバーへBotを招待できます

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3551653/c6df211a-6db6-9a35-1cf9-b0427a678b55.png)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3551653/f7bc9bb3-f404-cf1f-9fe3-d0780dbd38f9.png)


これでサーバー設定の アプリ → 連携サービス を選択するとBotが追加されているはずです！

# Botを動かす

https://github.com/bwmarrin/discordgo

この章ではdiscordgoというライブラリを使って、Botを作成していきます

discord/session.go でセッションのメソッドを定義しています

ここで使用するトークンはBot作成時にコピーしたトークンです

セッションの作成自体は

dg, err := discordgo.New("Bot " + token)

この部分です

```go
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内で先ほど作成したセッション作成メソッドを使い、セッションをオープンしています

トークンは環境変数から取り出すことで、コードに直で書かないようにしています

そしてオープンしたセッションを停止入力されるまで待機し続けます

```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つの処理が必要で、

1. コマンドの名前や引数、説明などの設定
2. コマンドを受け取った際の処理
3. セッションへのコマンドの登録

これらの処理を行うことでコマンドを作成できます

このスクリプトでは上記の3の処理を行っています

```go
// 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文でコマンドの名前で判別しています

```go
// 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の処理を行っています

```go
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)
}

```

# リマインド機能をつくる

https://github.com/go-co-op/gocron

gocron というGo言語でcronのような形式でリマインドを作成出来るライブラリを使用する

- そもそもcronとは？
    
    Linux系のOSに搭載されているリマインダー機能
    
    ジョブという形式で様々な動作を定期実行できる機能
    
    分 時 日 月 曜日 <実行コマンド>
    
    この形式で作成する
    
    例: 0 15 * * * echo "hello.”
    
    この他にも様々な指定方法があるが、割愛
    

```go
package utils

// JobData は、チーム、cronスケジュール、および役職を持つジョブの構造体
type JobData struct {
	Team string `json:"team"`
	Cron string `json:"cron"`
	Role string `json:"role"`
}

```

```go
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 ライブラリを使用する

読み込んだデータをスケジュールへ登録している

```go
// initializeScheduleはスケジューラを初期化する
func initializeSchedule() {
	for _, jobData := range commands.JobDataSlice {
		s.RegisterJob(jobData.Cron, scheduler.SendRemindMessage, jobData.Team, jobData.Role, dgs)
	}
	s.Start()
}
```

スケジュールデータをjson形式でローカルへ書き込み、読み込みを行っている

```go
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というライブラリを利用する

[godotenvの使い方をざっくりまとめる](https://zenn.dev/yukihaga/scraps/19b101e0faf857)

```go
// 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"))
}
```

```go
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](https://pkg.go.dev/log)

log / slog

[slog package - log/slog - Go Packages](https://pkg.go.dev/log/slog)

```go
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](https://pkg.go.dev/github.com/bwmarrin/discordgo)

[GoでDiscordBotを作る](https://zenn.dev/cohky/articles/go-discord-bot)

gocron

[gocron v2で定期実行を走らせる](https://zenn.dev/ukiyocreate_dev/articles/golang_gocron_v2)

slog

[Goのslog使い方まとめ - Qiita](https://qiita.com/Imamotty/items/3fbe8ce6da4f1a653fae)

[🪵 Go1.21 log/slogパッケージ超入門](https://zenn.dev/88888888_kota/articles/7e97ff874083cf)

Discord Bot 仕様

[discordのbotにカスタム絵文字を使わせる](https://nichiafi.blogspot.com/2018/02/discordbot.html)