GoでDiscordBotを書きたかった



## 経緯
いつもはPythonでDiscordのBotを作っているのですが、
公開するBotとしてはパフォーマンスが良さそうなGoで書いたら高速で動作させられるんでね？
と浅はかな私はdiscordgoに触れてみました。
>本記事はGoでDiscordのSlashcommandを作成してするまでの内容です。
Go初心者が書くものなので間違っている部分もあるかもしれないです。

## discordgoに関する日本語の情報がほとんどないです

とりあえずdiscordgoに関する記事として以下を参考をしましたが、
Slashcommandはわりと最近のため、その類の情報が乗っていませんでした。

https://qiita.com/s3pt3mb3r/items/e691c878e45235a8a9e2

https://zenn.dev/cohky/articles/go-discord-bot

## 公式のExampleを参考にして書いてみる

GitHubに公開されている公式のExampleの中に書き方が乗っているので、それを参考にコーディングしていきます。

https://github.com/bwmarrin/discordgo/blob/master/examples/slash_commands/main.go

ただ、書いてみたのですがイマイチ書きづらいです。


以下Exampleのコマンド登録部分を要約したものです。
```go:main.go

func init() {
	// コマンドのフロント側を定義
	commands = []*discordgo.ApplicationCommand{
		{
			Name:        "hi",
			Description: "説明",
		},
	}
	
	// mapにフロント側のコマンドの名称で関数を定義
	commandHandlers = map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){
		"hi": func(s *discordgo.Session, i *discordgo.InteractionCreate) {
			s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
				Type: discordgo.InteractionResponseChannelMessageWithSource,
				Data: &discordgo.InteractionResponseData{
					Content: "hi",
				},
			})
		},
	}
}

```

これはPythonで書くとこれぐらい短い単純なコードです。
```py:main.py
@bot.slash_command(description="説明")
async def hi(ctx):
    ctx.respond("hi")
```

Goで書いた方はフロント部と関数が分かれており、関数を呼び出すmapの文字列とコマンドの文字列が別々に定義されているためあまり好ましい実装ではないと思います。

対処法としては変数に"hi"文字列を入れて共通させれば良い気もしますが、公式のExampleの様にまとめてフロント部を書いて関数のmapを書いた場合では、どちらにせよ可読性が悪くなってしまいます。

## とりあえずまとめて定義する方法を考える

とりあえずPycordのフレームワークを参考に同時に定義できれば少しはマシになるかなと思い以下の様にaddCommand関数を定義してみました。

```go:base.go
package commands

import (
	"fmt"

	"github.com/bwmarrin/discordgo"
)

var (
	Commands        = make([]*discordgo.ApplicationCommand, 0)
	CommandHandlers = make(map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate))
)

func addCommand(command *discordgo.ApplicationCommand, fn func(s *discordgo.Session, i *discordgo.InteractionCreate)) {
	_, exist := CommandHandlers[command.Name]
	if exist {
		panic(fmt.Sprintf("[%s] ← このコマンド名が重複しています！", command.Name))
	}
	// コマンド部分のNameをそのままmapのKeyとして設定しておく
	CommandHandlers[command.Name] = fn
	Commands = append(Commands, command)
}

```

```go:hello.go
package commands

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

func init() {
	// Nameで定義された文字列がKeyになるので同時に書ける
	addCommand(
		&discordgo.ApplicationCommand{
			Name:        "hi",
			Description: "説明",
		},
		func(s *discordgo.Session, i *discordgo.InteractionCreate) {
			s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{
				Type: discordgo.InteractionResponseChannelMessageWithSource,
				Data: &discordgo.InteractionResponseData{
					Content: "hi!",
				},
			})
		},
	)
}
```
DiscordのSlashcommandの仕様として登録時に同名のコマンドを送信した際に400エラーを返す仕様なので、予めこちらで重複するコマンドは弾いています。




Golangを初めて触るのでイマイチpackageの扱いなどがわかっていないですが、
以下の様なディレクトリ構造で考えています。
```
・
┣ main.go
┗ commands
   ┣ base.go
   ┣ hello.go
   ┗ ...
```
discord.pyなどのCogsの様にファイルごとに機能が分かれるようなイメージで構築できるので、まぁまぁ実装しやすい気がします。

mainから呼び出す際は以下のような形にしています。
```go:main.go
package main

import (
	"fmt"
	"os"
	"os/signal"
	"testbot/commands"

	"github.com/bwmarrin/discordgo"
	"github.com/joho/godotenv"
)

// .envファイルで定義します
var (
	GuildID        string
	BotToken       string
	RemoveCommands bool
)

var s *discordgo.Session

func init() {
	err := godotenv.Load(".env")

	if err != nil {
		panic(fmt.Sprintf("Dotenv read error: %v", err))
	}

	GuildID = os.Getenv("GUILDID")
	BotToken = os.Getenv("TOKEN")
	RemoveCommands = os.Getenv("REMOVECOMMAND") == "true"

	s, err = discordgo.New("Bot " + BotToken)
	if err != nil {
		panic(fmt.Sprintf("Invalid bot parameters: %v", err))
	}
}

var (
	commandList     = commands.Commands
	commandHandlers = commands.CommandHandlers
)

func init() {
	s.AddHandler(func(s *discordgo.Session, i *discordgo.InteractionCreate) {
		if h, ok := commandHandlers[i.ApplicationCommandData().Name]; ok {
			h(s, i)
		}
	})
}

func main() {
	s.AddHandler(func(s *discordgo.Session, r *discordgo.Ready) {
		fmt.Printf("fmtged in as: %v#%v", s.State.User.Username, s.State.User.Discriminator)
	})
	err := s.Open()
	if err != nil {
		panic(fmt.Sprintf("Cannot open the session: %v", err))
	}

	fmt.Println("Adding commands...")
	registeredCommands := make([]*discordgo.ApplicationCommand, len(commandList))
	for i, v := range commandList {
		cmd, err := s.ApplicationCommandCreate(s.State.User.ID, GuildID, v)
		if err != nil {
			s.Close()
			panic(fmt.Sprintf("Cannot create '%v' command: %v", v.Name, err))
		}
		registeredCommands[i] = cmd
	}

	defer s.Close()

	stop := make(chan os.Signal, 1)
	signal.Notify(stop, os.Interrupt)
	fmt.Println("Press Ctrl+C to exit")
	<-stop

	if RemoveCommands {
		fmt.Println("Removing commands...")
		for _, v := range registeredCommands {
			err := s.ApplicationCommandDelete(s.State.User.ID, GuildID, v.ID)
			if err != nil {
				fmt.Printf("Cannot delete '%v' command: %v", v.Name, err)
			}
		}
	}

	fmt.Println("Gracefully shutting down.")
}
```

基本的にはExampleの内容で、`.env`ファイルから情報を取得するように変更しているような形です。

ここまで書いてみてわかったのはPythonで書けるBotの手軽さが素晴らしいということでした。