discord.goの多機能Botテンプレート作ってみた
あいさつ
皆さんこんにちは!マグロです。
最近内定先でgolangを書いていて、慣れるためにも何か書いておきたいなーと考えてました。
というわけでdiscordgoを使った多機能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.go
やvc_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
で定義したスラッシュコマンドの型に当てはめます。
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
環境変数
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定義
package model
type IndexResponse struct {
Message string `json:"message"`
}
そのままです。
localhostにアクセスした際に、{message:"任意の文字列"}
がレスポンスとして帰ってきます。
server_router
Botのセッション情報の引き渡し
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サーバーのルータ設定
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ページ
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でホスティングする方法も記しておきます。
1.Create App
を選択。
2.GitHub
を選択。
3.DiscordGoで書いたBotのリポジトリを選択。
4.以下の画面のように選択。
5.下にスクロールし、Advanced
をクリックして環境変数を設定。
6.Deploy
でデプロイ。
終わりに
discordgoがあまり盛り上がらないのは、多機能Botの作りずらさも一因なのかなーと考えています。
特にスラッシュコマンドの登録方法の情報は少なく、公式のサンプルコードもクソ長くて「書かせる気あるのか?」とか思ってます。
とはいえこうすれば多少は書きやすくなるとは思うので、Golangの勉強がてらにdiscordgoを触ってみるのもいいのではないのでしょうか?
余談
当初はpostgresとsqliteに対応させたものを公開していましたが、複雑さを排除するためあえて削除いたしました。
現在、データベースに加えてDiscord OAuth2+React or Nextでのアプリ開発も行っています。
ご興味あれば完成までお待ちいただければと思います。
Discussion