diff --git a/bot/bot.go b/bot/bot.go new file mode 100644 index 0000000..f8a4c50 --- /dev/null +++ b/bot/bot.go @@ -0,0 +1,61 @@ +package bot + +import ( + "fmt" + "log/slog" + "os" + "os/signal" + "syscall" + + "github.com/bwmarrin/discordgo" + "github.com/yechentide/mhnow-bot/config" +) + +func Run() { + slog.Info("Botを起動しています ...") + discord, err := discordgo.New(config.GetBotToken()) + if err != nil { + slog.Error("Botの初期化に失敗しました", "error", err) + os.Exit(1) + } + + discord.AddHandler(onMessageCreate) + discord.AddHandler(onMessageUpdate) + discord.AddHandler(onMessageDelete) + + discord.AddHandler(onMessageReactionAdd) + discord.AddHandler(onMessageReactionRemove) + discord.AddHandler(onMessageReactionRemoveAll) + + discord.AddHandler(onInteractionCreated) + + err = discord.Open() + if err != nil { + slog.Error("Botのログインに失敗しました", "error", err) + os.Exit(1) + } + + defer func() { + discord.Close() + fmt.Println("") + slog.Info("Botを終了しました") + }() + + for _, guild := range discord.State.Guilds { + RegisterCommands(discord, guild.ID) + } + + slog.Info("========== ========== ========== Monster Hunter Now - Discord Bot ========== ========== ==========") + stopBot := make(chan os.Signal, 1) + signal.Notify(stopBot, syscall.SIGINT, syscall.SIGTERM, os.Interrupt, syscall.SIGTERM) + <-stopBot +} + +/* +GuildMemberAdd +GuildMemberUpdate +GuildMemberRemove +GuildEmojisUpdate +UserUpdate +MessageDeleteBulk +*/ diff --git a/bot/handle-Interaction.go b/bot/handle-Interaction.go new file mode 100644 index 0000000..66134b6 --- /dev/null +++ b/bot/handle-Interaction.go @@ -0,0 +1,37 @@ +package bot + +import ( + "fmt" + "log/slog" + + "github.com/bwmarrin/discordgo" + cmds "github.com/yechentide/mhnow-bot/commands" +) + +func onInteractionCreated(s *discordgo.Session, i *discordgo.InteractionCreate) { + switch i.Type { + case discordgo.InteractionApplicationCommand: + slog.Debug("Interaction Created", "Command", i) + commandHandler(s, i) + case discordgo.InteractionMessageComponent: + slog.Debug("Interaction Created", "MessageComponent", i) + case discordgo.InteractionModalSubmit: + slog.Debug("Interaction Created", "ModalSubmit", i) + default: + slog.Debug("Interaction Created", i.Type, i) + } +} + +func commandHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + hunterName := i.Member.Nick + if len(hunterName) == 0 { + hunterName = i.Member.User.Username + } + slog.Info(fmt.Sprintf("%s がコマンド %s を実行しました", hunterName, i.ApplicationCommandData().Name)) + switch i.ApplicationCommandData().Name { + case "paint": + cmds.PaintHandler(s, i) + case "paint-list": + cmds.PaintListHandler(s, i) + } +} diff --git a/bot/handle-message-reaction.go b/bot/handle-message-reaction.go new file mode 100644 index 0000000..3b2fe53 --- /dev/null +++ b/bot/handle-message-reaction.go @@ -0,0 +1,81 @@ +package bot + +import ( + "fmt" + "log/slog" + + "github.com/bwmarrin/discordgo" +) + +/* +MessageReactionAdd +MessageReactionRemove +MessageReactionRemoveAll +*/ + +func onMessageReactionAdd(s *discordgo.Session, r *discordgo.MessageReactionAdd) { + snowflake := r.Emoji.Name + ":" + r.Emoji.ID + if r.GuildID == "" { + slog.Debug(fmt.Sprintf( + "Direct reaction added: Channel=%s, Guild=%s, MessageID=%s, Emoji=%s", + r.ChannelID, + r.GuildID, + r.MessageID, + snowflake, + )) + } else { + slog.Debug(fmt.Sprintf( + "Guild reaction added: Channel=%s, Guild=%s, Author=%s(%s)_%-20s, MessageID=%s, Emoji=%s", + r.ChannelID, + r.GuildID, + r.Member.User.Username, + r.Member.Nick, + r.Member.User.ID, + r.MessageID, + snowflake, + )) + } +} + +func onMessageReactionRemove(s *discordgo.Session, r *discordgo.MessageReactionRemove) { + snowflake := r.Emoji.Name + ":" + r.Emoji.ID + if r.GuildID == "" { + slog.Debug(fmt.Sprintf( + "Direct reaction removed: Channel=%s, Guild=%s, User=%s, MessageID=%s, Emoji=%s", + r.ChannelID, + r.GuildID, + r.UserID, + r.MessageID, + snowflake, + )) + } else { + slog.Debug(fmt.Sprintf( + "Guild reaction removed: Channel=%s, Guild=%s, User=%s, MessageID=%s, Emoji=%s", + r.ChannelID, + r.GuildID, + r.UserID, + r.MessageID, + snowflake, + )) + } +} + +func onMessageReactionRemoveAll(s *discordgo.Session, r *discordgo.MessageReactionRemoveAll) { + if r.GuildID == "" { + slog.Debug(fmt.Sprintf( + "All direct reaction removed: Channel=%s, Guild=%s, User=%s, MessageID=%s", + r.ChannelID, + r.GuildID, + r.UserID, + r.MessageID, + )) + } else { + slog.Debug(fmt.Sprintf( + "All guild reaction removed: Channel=%s, Guild=%s, User=%s, MessageID=%s", + r.ChannelID, + r.GuildID, + r.UserID, + r.MessageID, + )) + } +} diff --git a/bot/handle-message.go b/bot/handle-message.go new file mode 100644 index 0000000..7b78d97 --- /dev/null +++ b/bot/handle-message.go @@ -0,0 +1,69 @@ +package bot + +import ( + "fmt" + "log/slog" + + "github.com/bwmarrin/discordgo" +) + +/* +MessageCreate +MessageUpdate +MessageDelete +*/ + +func isBotMentionedInMessage(s *discordgo.Session, mentions []*discordgo.User) bool { + for _, mention := range mentions { + if mention.ID == s.State.User.ID { + return true + } + } + return false +} + +func onMessageCreate(s *discordgo.Session, m *discordgo.MessageCreate) { + if m.GuildID == "" { + onDirectMessageCreated(s, m) + } else if isBotMentionedInMessage(s, m.Mentions) { + onGuildMessageCreated(s, m) + } +} + +func onMessageUpdate(s *discordgo.Session, m *discordgo.MessageUpdate) { + if !isBotMentionedInMessage(s, m.Mentions) { + return + } + slog.Info(fmt.Sprintf( + "Message updated: Channel=%s, Guild=%s, Author=%s_%-20s\n%s", + m.ChannelID, + m.GuildID, + m.Author.Username, + m.Author.ID, + m.Content, + )) +} + +func onMessageDelete(s *discordgo.Session, m *discordgo.MessageDelete) {} + +func onGuildMessageCreated(s *discordgo.Session, m *discordgo.MessageCreate) { + slog.Info(fmt.Sprintf( + "Guild message created: Channel=%s, Guild=%s, Author=%s(%s)_%-20s\n%s", + m.ChannelID, + m.GuildID, + m.Author.Username, + m.Member.Nick, + m.Author.ID, + m.Content, + )) +} + +func onDirectMessageCreated(s *discordgo.Session, m *discordgo.MessageCreate) { + slog.Info(fmt.Sprintf( + "Direct message created: Channel=%s, Author=%s_%-20s\n%s", + m.ChannelID, + m.Author.Username, + m.Author.ID, + m.Content, + )) +} diff --git a/bot/message.go b/bot/message.go new file mode 100644 index 0000000..d504cbd --- /dev/null +++ b/bot/message.go @@ -0,0 +1,41 @@ +package bot + +import ( + "fmt" + + "github.com/bwmarrin/discordgo" +) + +func SendMessage(s *discordgo.Session, channelID string, msg string) { + _, err := s.ChannelMessageSend(channelID, msg) + fmt.Println(">>> " + msg) + if err != nil { + fmt.Println("Error sending message: ", err) + } +} + +func SendReply(s *discordgo.Session, channelID string, reference *discordgo.MessageReference, msg string) { + _, err := s.ChannelMessageSendReply(channelID, msg, reference) + if err != nil { + fmt.Println("Error sending message: ", err) + } +} + +func SendEmbedMessage(s *discordgo.Session, channelID, title, desc string, color int) { + embed := &discordgo.MessageEmbed{ + Title: title, + Description: desc, + Color: color, + } + _, err := s.ChannelMessageSendEmbed(channelID, embed) + if err != nil { + fmt.Println("Error sending embed message: ", err) + } +} + +func SendReaction(s *discordgo.Session, channelID, messageID, reaction string) { + err := s.MessageReactionAdd(channelID, messageID, reaction) + if err != nil { + fmt.Println("Error add a reaction: ", err) + } +} diff --git a/bot/register-cmds.go b/bot/register-cmds.go new file mode 100644 index 0000000..f2cc4b0 --- /dev/null +++ b/bot/register-cmds.go @@ -0,0 +1,26 @@ +package bot + +import ( + "fmt" + "log/slog" + + "github.com/bwmarrin/discordgo" + cmds "github.com/yechentide/mhnow-bot/commands" +) + +func RegisterCommands(s *discordgo.Session, guildId string) { + registerMessage := fmt.Sprintf("Registering commands for guild %s ...", guildId) + slog.Info(registerMessage) + + paintListCmd := cmds.PaintListCommand() + paintCmd, err := cmds.PaintCommand() + if err != nil { + panic(err) + } + _, err = s.ApplicationCommandBulkOverwrite(s.State.User.ID, guildId, []*discordgo.ApplicationCommand{ + paintListCmd, paintCmd, + }) + if err != nil { + panic(err) + } +} diff --git a/commands/common.go b/commands/common.go new file mode 100644 index 0000000..8fcc75e --- /dev/null +++ b/commands/common.go @@ -0,0 +1,17 @@ +package commands + +import ( + "github.com/bwmarrin/discordgo" +) + +func sendInteractionRespondMessage(s *discordgo.Session, i *discordgo.InteractionCreate, message string) { + s.InteractionRespond( + i.Interaction, + &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: message, + }, + }, + ) +} diff --git a/commands/paint-list.go b/commands/paint-list.go new file mode 100644 index 0000000..b7b8f76 --- /dev/null +++ b/commands/paint-list.go @@ -0,0 +1,56 @@ +package commands + +import ( + "fmt" + + "github.com/bwmarrin/discordgo" + "github.com/yechentide/mhnow-bot/dao" + "github.com/yechentide/mhnow-bot/utils" +) + +func PaintListCommand() *discordgo.ApplicationCommand { + return &discordgo.ApplicationCommand{ + Type: 1, + Name: "paint-list", + Description: "消滅していないモンスターの一覧を表示します", + } +} + +func PaintListHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + query := dao.New(dao.GetDb()) + monsters, err := query.FindHuntableMontersWithGuild(dao.GetCtx()) + if err != nil { + sendInteractionRespondMessage(s, i, "データの取得に失敗しました") + return + } + + msg := "" + if len(monsters) == 0 { + msg = "消滅していないモンスターの一覧はありません" + sendInteractionRespondMessage(s, i, msg) + return + } + + emojiMap := utils.GenerateGuildEmojiMap(s, i.GuildID) + for _, monster := range monsters { + monsterName := monster.MonsterJpName + snowflake, ok := (*emojiMap)[monster.MonsterID] + if ok { + monsterName = snowflake + } + jstTime := monster.DisappearAt.Format("01/02_15:04") + location := monster.Location.String + if location == "" { + location = "???" + } + msg += fmt.Sprintf( + "## %s(R%d) *%s*まで\n> %sが%sで発見した\n", + monsterName, + monster.Rank, + jstTime, + monster.HunterName, + location, + ) + } + sendInteractionRespondMessage(s, i, msg) +} diff --git a/commands/paint.go b/commands/paint.go new file mode 100644 index 0000000..d3427d1 --- /dev/null +++ b/commands/paint.go @@ -0,0 +1,128 @@ +package commands + +import ( + "database/sql" + "fmt" + + "github.com/bwmarrin/discordgo" + "github.com/yechentide/mhnow-bot/dao" + "github.com/yechentide/mhnow-bot/utils" +) + +func PaintCommand() (*discordgo.ApplicationCommand, error) { + query := dao.New(dao.GetDb()) + monters, err := query.ListDiscoverableMonsters(dao.GetCtx()) + if err != nil { + return nil, err + } + + choices := []*discordgo.ApplicationCommandOptionChoice{} + for _, m := range monters { + choices = append(choices, &discordgo.ApplicationCommandOptionChoice{ + Name: m.JpName, + Value: m.ID, + }) + } + + return &discordgo.ApplicationCommand{ + Type: 1, + Name: "paint", + Description: "ペイントしたモンスターを登録します", + Options: []*discordgo.ApplicationCommandOption{ + { + Name: "ランク", + Type: 4, + Required: true, + Description: "数字で入力してください", + }, + { + Name: "種類", + Type: 3, + Required: true, + Description: "モンスター名を入力してください", + Choices: choices, + }, + { + Name: "消滅日時", + Type: 3, + Required: true, + Description: "例: 09/09 12:30", + }, + { + Name: "場所", + Type: 3, + Required: false, + Description: "任意です", + }, + }, + }, err +} + +func registerHunterIfNotExist(s *discordgo.Session, i *discordgo.InteractionCreate) error { + hQuery := dao.New(dao.GetDb()) + name := i.Member.User.Username + nickName := i.Member.Nick + + _, err := hQuery.GetHunter(dao.GetCtx(), i.Member.User.ID) + if err != sql.ErrNoRows { + return err + } + _, err = hQuery.RegisterHunter(dao.GetCtx(), dao.RegisterHunterParams{ + ID: i.Member.User.ID, + Name: name, + DisplayName: sql.NullString{String: nickName, Valid: true}, + }) + return err +} + +func PaintHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { + opts := i.ApplicationCommandData().Options + rank := opts[0].IntValue() + monsterID := opts[1].StringValue() + disappearAt, err := utils.ConvertDateInput(opts[2].StringValue(), "Asia/Tokyo") + if err != nil { + msg := fmt.Sprintf("<@%s>\n日時のフォーマットを見直してください\n入力した値: %s", i.Member.User.ID, opts[2].StringValue()) + sendInteractionRespondMessage(s, i, msg) + return + } + location := "" + if len(opts) >= 4 { + location = opts[3].StringValue() + } + + err = registerHunterIfNotExist(s, i) + if err != nil { + msg := fmt.Sprintf("<@%s>\nハンター情報が見つかりません", i.Member.User.ID) + sendInteractionRespondMessage(s, i, msg) + return + } + + phQuery := dao.New(dao.GetDb()) + _, err = phQuery.PaintMonster(dao.GetCtx(), dao.PaintMonsterParams{ + Rank: int16(rank), + HunterID: i.Member.User.ID, + MonsterID: monsterID, + DisappearAt: disappearAt, + Location: sql.NullString{String: location, Valid: true}, + }) + if err != nil { + msg := fmt.Sprintf("<@%s>\nデータの保存に失敗しました", i.Member.User.ID) + sendInteractionRespondMessage(s, i, msg) + return + } + + snowflake := utils.GetEmojiSnowflake(s, i.GuildID, monsterID) + msg := fmt.Sprintf("%s(R%d) %sまで", snowflake, rank, opts[2].StringValue()) + if location != "" { + msg += fmt.Sprintf("\n%s", location) + } + s.InteractionRespond( + i.Interaction, + &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: msg, + }, + }, + ) +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..ecca2f2 --- /dev/null +++ b/main.go @@ -0,0 +1,24 @@ +package main + +import ( + "log/slog" + + _ "github.com/lib/pq" + "github.com/yechentide/mhnow-bot/bot" + "github.com/yechentide/mhnow-bot/config" + "github.com/yechentide/mhnow-bot/dao" + "github.com/yechentide/mhnow-bot/migrations" +) + +func main() { + config.LoadConfig() + + db := dao.GetDb() + migrations.Setup() + defer func() { + db.Close() + slog.Info("Disconnected from database.") + }() + + bot.Run() +} diff --git a/utils/date.go b/utils/date.go new file mode 100644 index 0000000..7ff9825 --- /dev/null +++ b/utils/date.go @@ -0,0 +1,25 @@ +package utils + +import "time" + +func ParseStringDate(dateString string, local string) (time.Time, error) { + tz, _ := time.LoadLocation(local) + date, err := time.ParseInLocation("01/02 15:04", dateString, tz) + if err != nil { + return time.Time{}, err + } + return date, nil +} + +func ConvertDateInput(dateString string, local string) (time.Time, error) { + date, err := ParseStringDate(dateString, local) + if err != nil { + return time.Time{}, err + } + currentDate := time.Now() + date = date.AddDate(currentDate.Year(), 0, 0) + if date.Before(currentDate) { + date = date.AddDate(1, 0, 0) + } + return date, nil +} diff --git a/utils/snowflake.go b/utils/snowflake.go new file mode 100644 index 0000000..442b55a --- /dev/null +++ b/utils/snowflake.go @@ -0,0 +1,25 @@ +package utils + +import ( + "fmt" + + "github.com/bwmarrin/discordgo" +) + +func GetEmojiSnowflake(s *discordgo.Session, guildID, monsterID string) string { + m := GenerateGuildEmojiMap(s, guildID) + snowflake, ok := (*m)[monsterID] + if ok { + return snowflake + } + return monsterID +} + +func GenerateGuildEmojiMap(s *discordgo.Session, guildID string) *map[string]string { + m := map[string]string{} + emojis, _ := s.GuildEmojis(guildID) + for _, e := range emojis { + m[e.Name] = fmt.Sprintf("<:%s:%s>", e.Name, e.ID) + } + return &m +}