🔨

discord.goの多機能Botテンプレート作ってみた

2023/10/09に公開

あいさつ

皆さんこんにちは!マグロです。
最近内定先でgolangを書いていて、慣れるためにも何か書いておきたいなーと考えてました。
というわけでdiscordgoを使った多機能Botのテンプレートを作りました。

https://github.com/maguro-alternative/discord_go_bot

httpサーバーの立ち上げ、各機能のイベントハンドラーの登録、スラッシュコマンドの登録などができます。

使いたい場合は上記をクローンしてREADMEを参考に各種設定をしてください。

今回説明しないもの

  • DiscordBotの登録
  • 各種権限、スラッシュコマンドの説明

ディレクトリ構成

以下のようなディレクトリ構成になっています。

$ tree
.
├── bot_handler
│   ├── bot_router                   # Botのハンドラー設定
│   │   ├── command.go               # スラッシュコマンドのハンドラー設定
│   │   └── handler.go               # イベントハンドラの設定
│   ├── message_create.go            # テキストチャンネルに何か発言があった場合
│   └── vc_signal.go                 # ボイスチャンネルのステータス変化があった場合
├── commands
│   ├── disconnect_voice_channel.go  # ボイスチャンネルから切断するスラッシュコマンド
│   ├── ping.go                      # ping確認するスラッシュコマンド
│   └── start_record.go              # 録音を開始するスラッシュコマンド
├── model
│   ├── envconfig                    # 環境変数設定
│   │   └── env.go
│   └── index.go                     # サーバーのホームアクセス時のレスポンス
├── server_handler
│   ├── router
│   │   └── router.go                # サーバーのルータ設定
│   └── index.go                     # ホーム
├── service
│   └── index.go
├── Dockerfile
├── .env
├── go.mod
├── go.sum
└── main.go                         # Bot+サーバーの立ち上げ

とりあえず各ファイルの役割をささっと説明します。

main.go

Botの立ち上げとhttpサーバーの立ち上げを行っています。
ポイントはこちら。

イベントハンドラーの登録
	// ハンドラーの登録
	botRouter.RegisterHandlers(discord)

botRouterからイベントハンドラを呼び出し、登録しています。
message_create.govc_signal.goが呼び出され、イベントに応じた処理が可能になります。
詳細はbot_handlerで説明します。

スラッシュコマンドの登録
	var commandHandlers []*botRouter.Handler
	// 所属しているサーバすべてにスラッシュコマンドを追加する
	// NewCommandHandlerの第二引数を空にすることで、グローバルでの使用を許可する
	commandHandler := botRouter.NewCommandHandler(discord, "")
	// 追加したいコマンドをここに追加
	commandHandler.CommandRegister(commands.PingCommand())
	commandHandler.CommandRegister(commands.RecordCommand())
	commandHandler.CommandRegister(commands.DisconnectCommand())
	commandHandlers = append(commandHandlers, commandHandler)

botRouterからスラッシュコマンドを呼び出し、登録しています。
commands配下のコマンドを上記のように書き込みしているので、使いたくないコマンドはコメントアウトすることができます。
グローバルで登録するので、Botが所属するサーバーすべてでスラッシュコマンドを使用することができます。
commandHandlersは後述するスラッシュコマンドの削除で使用します。

httpサーバー立ち上げ
	// ここでサーバーを起動すると、Ctrl+Cで終了するまでサーバーが起動し続ける
	go func() {
		const (
			defaultPort   = ":8080"
		)

		port := env.ServerPort
		if port == "" {
			port = defaultPort
		}

		mux := router.NewRouter()
		log.Printf("Serving HTTP port: %s\n", port)
		log.Fatal(http.ListenAndServe(port, mux))
	}()

go文を使用することで、Bot起動と並列でhttpサーバーを起動させることができます。
ルータに関する説明はserver_handlerでします。

終了時のスラッシュコマンドの削除
	// Ctrl+Cを受け取るためのチャンネル
	sc := make(chan os.Signal, 1)
	// Ctrl+Cを受け取る
	signal.Notify(sc, os.Interrupt)
	<-sc //プログラムが終了しないようロック

	fmt.Println("Removing commands...")

	// コマンドを削除
	for i := range commandHandlers {
		// すべてのコマンドを取得
		commands := commandHandlers[i].GetCommands()
		for _, command := range commands {
			err := commandHandlers[i].CommandRemove(command)
			if err != nil {
				panic("error removing command")
			}
		}
	}

signal.Notifyでプログラムの実行をロックします。
Ctrl+Cが押された場合終了し、起動時に登録したスラッシュコマンドの削除を行います。

bot_handler/bot_router

イベントハンドラ登録
// ハンドラーの登録
func RegisterHandlers(s *discordgo.Session) {
	fmt.Println(s.State.User.Username + "としてログインしました")
	s.AddHandler(botHandler.OnMessageCreate)
	s.AddHandler(botHandler.OnVoiceStateUpdate)
}

それぞれテキストメッセージのイベントとボイスチャンネルのステータス変化のイベントになります。
セッションにAddHandlerすることで登録をしています。

各イベントはdiscordgo公式や他の記事にあるように書くことができます。
例:botHandler.OnMessageCreate(bot以外のテキストメッセージをオウム返し)

// message_create.go
package botHandler

import (
	"fmt"

	"github.com/bwmarrin/discordgo"
)

func OnMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate) {
    // メッセージが作成されたときに実行する処理
	//u := m.Author

    fmt.Println(m.Content)

    if(m.Author.Bot == false){
        s.ChannelMessageSend(m.ChannelID, m.Content)
    }
}

余談ですが、変数名は特に関係はなく、イベントは関数の引数の変化から判断するようです。
(m *discordgo.MessageCreateならテキストメッセージ送信、vs *discordgo.VoiceStateUpdateの場合はボイスチャンネルのステータス変化)
この影響かスラッシュコマンドの扱いが少々大変になっています。
詳細はcommandsで説明します。

スラッシュコマンドの型作成
// スラッシュコマンドの作成
func NewCommandHandler(session *discordgo.Session, guildID string) *Handler {
	return &Handler{
		session:  session,
		commands: make(map[string]*Command),
		guild:    guildID,
	}
}

bot_handler/bot_router/command.goで定義したスラッシュコマンドの型に当てはめます。

bot_handler/bot_router/command.go
type Command struct {
	Name        string
	Aliases     []string
	Description string
	Options     []*discordgo.ApplicationCommandOption
	AppCommand  *discordgo.ApplicationCommand
	Executor    func(s *discordgo.Session, i *discordgo.InteractionCreate)
}
  • Nameはコマンド名
  • Aliasesは別名(定義しなくていい)
  • Descriptionはコマンドの説明
  • Optionはスラッシュコマンドのオプション(discord.go公式のサンプルコード参照)
  • AppCommandは登録されたのスラッシュコマンドの型
  • Executorは実行する関数

定義の方法はcommandsで説明します。

スラッシュコマンドの登録
	appCmd, err := h.session.ApplicationCommandCreate(
		h.session.State.User.ID,
		h.guild,
		&discordgo.ApplicationCommand{
			//ID:            command.AppCommand.ID,
			ApplicationID: h.session.State.User.ID,
			//GuildID:       h.guild,
			Name:          command.Name,
			Description:   command.Description,
			Options:       command.Options,
		},
	)

discordgoでは、セッションのApplicationCommandCreateを使用してスラッシュコマンドの登録をします。
引数にはguildIDを指定しなければいけないのですが、空の文字列を引き渡すとグローバルコマンドとして登録してくれるそうです。ややこしいですね。

	command.AddApplicationCommand(appCmd)

	h.commands[command.Name] = command

	h.session.AddHandler(
		func(s *discordgo.Session, i *discordgo.InteractionCreate) {
			command.Executor(s, i)
		},
	)

登録したスラッシュコマンドをAppCommandに格納します。
スラッシュコマンドを削除、取得する際に使用します。
また、ハンドラーの方にもスラッシュコマンドを格納しておきます。

最後にイベントハンドラと同様にスラッシュコマンドを登録します。
ここで登録しているのは関数なので、スラッシュコマンドを入力された場合登録されたコマンド名関係なくすべて実行されてしまいます。
対策は後述。

commands

スラッシュコマンドの定義

pingコマンドを例にするとこんな感じに書くことができます。

package commands

import (
	"fmt"

	"github.com/bwmarrin/discordgo"
	botRouter "github.com/maguro-alternative/discord_go_bot/bot_handler/bot_router"
)

func PingCommand() *botRouter.Command {
	/*
		pingコマンドの定義

		コマンド名: ping
		説明: Pong!
		オプション: なし
	*/
	return &botRouter.Command{
		Name:        "ping",
		Description: "Pong!",
		Options:     []*discordgo.ApplicationCommandOption{},
		Executor:    handlePing,
	}
}

func handlePing(s *discordgo.Session, i *discordgo.InteractionCreate) {
	/*
		pingコマンドの実行

		コマンドの実行結果を返す
	*/
	if i.Interaction.ApplicationCommandData().Name == "ping" {
		if i.Interaction.GuildID == i.GuildID {
			err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
				Type: discordgo.InteractionResponseChannelMessageWithSource,
				Data: &discordgo.InteractionResponseData{
					Content: "Pong",
				},
			})
			if err != nil {
				fmt.Printf("error responding to ping command: %v\n", err)
			}
		}
	}

}

これはpingのスラッシュコマンドを使用するとpongが返ってくるコマンドになります。
重要な点として、スラッシュコマンド登録の際に名前の見分けがつかない問題がありましたが、if i.Interaction.ApplicationCommandData().Name == "ping"とコマンド名で判別させて解決しています。
新たにコマンドを作成する場合、ここに一番気をつけてください。

model

環境変数
envconfig/env.go
package envconfig

import (
	"os"

	"github.com/joho/godotenv"
)

type Env struct {
	TOKEN            string
	ServerPort       string
}

func NewEnv() (*Env, error) {
	err := godotenv.Load(".env")
	if err != nil {
		return nil, err
	}

	return &Env{
		TOKEN:            os.Getenv("TOKEN"),
		ServerPort:       os.Getenv("PORT"),
	}, nil
}

.envファイルからトークンとhttpサーバーのポート番号を読み取っています。
NewEnvすればどこからでも呼び出せます。

httpホームのjson定義
index.go
package model

type IndexResponse struct {
	Message string `json:"message"`
}

そのままです。
localhostにアクセスした際に、{message:"任意の文字列"}がレスポンスとして帰ってきます。

server_router

Botのセッション情報の引き渡し
service/index.go
package service

import (
	"github.com/bwmarrin/discordgo"
)

type IndexService struct {
	DiscordSession *discordgo.Session
}

// IndexServiceを返す
func NewIndexService(discordSession *discordgo.Session) *IndexService {
	return &IndexService{
		DiscordSession : discordSession,
	}
}

IndexService内にBotのセッション情報を示すDiscordSessionを入れています。
これをサーバーに引き渡します。

httpサーバーのルータ設定
router/router.go
package router

import (
	"net/http"


	"github.com/maguro-alternative/discord_go_bot/server_handler"
	"github.com/maguro-alternative/discord_go_bot/service"

	"github.com/bwmarrin/discordgo"
)

func NewRouter(discordSession *discordgo.Session) *http.ServeMux {
	// *service.IndexService型変数を作成する。
	var indexService = service.NewIndexService(discordSession)

	// register routes
	mux := http.NewServeMux()
	mux.HandleFunc("/", serverHandler.NewIndexHandler(indexService).ServeHTTP)
	return mux
}

httpサーバーのルーティング設定を行います。
IndexServiceにdiscordのセッションを格納します。
こうすることで、webサーバー側からもBotの情報の参照、メッセージの送信といった操作が可能になります。
ServeHTTP/にアクセスされた場合の処理を記述します。

indexページ
index.go
package serverHandler

import (
	"encoding/json"
	"log"
	"net/http"

	"github.com/maguro-alternative/discord_go_bot/model"
	"github.com/maguro-alternative/discord_go_bot/service"
)

type IndexHandler struct {
	svc *service.IndexService
}

// http.HandlerをベースにしたIndexHandlerを返す
func NewIndexHandler(svc *service.IndexService) *IndexHandler {
	return &IndexHandler{
		svc: svc,
	}
}

func (h *IndexHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	err := json.NewEncoder(w).Encode(&model.IndexResponse{
		Message: h.svc.DiscordSession.State.User.Username + " is running",
	})
	if err != nil {
		log.Println(err)
	}
}

IndexHandlerにはBotのセッション情報が入っています。
h.svc.DiscordSession.State.User.UsernameはBotの名前を示しています。
json.NewEncoder(w)でレスポンスを返します。
この場合、{Message:"Bot名 is running"}がjsonとして戻ってきます。

Koyebでホスティング

ついでにKoyebでホスティングする方法も記しておきます。

https://app.koyeb.com/

1.Create Appを選択。

2.GitHubを選択。

3.DiscordGoで書いたBotのリポジトリを選択。

4.以下の画面のように選択。

5.下にスクロールし、Advancedをクリックして環境変数を設定。

6.Deployでデプロイ。

終わりに

discordgoがあまり盛り上がらないのは、多機能Botの作りずらさも一因なのかなーと考えています。
特にスラッシュコマンドの登録方法の情報は少なく、公式のサンプルコードもクソ長くて「書かせる気あるのか?」とか思ってます。
とはいえこうすれば多少は書きやすくなるとは思うので、Golangの勉強がてらにdiscordgoを触ってみるのもいいのではないのでしょうか?

余談

当初はpostgresとsqliteに対応させたものを公開していましたが、複雑さを排除するためあえて削除いたしました。
現在、データベースに加えてDiscord OAuth2+React or Nextでのアプリ開発も行っています。
ご興味あれば完成までお待ちいただければと思います。

https://github.com/maguro-alternative/discord_go_bot_pro

Discussion