0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Qiita全国学生対抗戦Advent Calendar 2024

Day 17

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

Posted at

概要

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一覧が表示されます (初めての場合は何も表示されません)

image.png

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

image.png

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

image.png

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

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

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

image.png

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

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

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

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

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

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

image.png

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

image.png

image.png

これでサーバー設定の アプリ → 連携サービス を選択すると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つの処理が必要で、

  1. コマンドの名前や引数、説明などの設定
  2. コマンドを受け取った際の処理
  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というライブラリを利用する

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

GoでDiscordBotを作る

gocron

gocron v2で定期実行を走らせる

slog

Goのslog使い方まとめ - Qiita

🪵 Go1.21 log/slogパッケージ超入門

Discord Bot 仕様

discordのbotにカスタム絵文字を使わせる

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?