diff --git a/src/internal/bot/command/commands.go b/src/internal/bot/command/commands.go index da2f1256..31824ae6 100644 --- a/src/internal/bot/command/commands.go +++ b/src/internal/bot/command/commands.go @@ -10,6 +10,9 @@ import ( var Commands = []*discordgo.ApplicationCommand{ general.LoadPingCommandContext(), general.LoadAboutCommandContext(), + general.LoadPinCommandContext(), + general.LoadUnpinCommandContext(), + general.LoadPinSelectCommandContext(), general.LoadTtsCommandContext(), general.LoadHelpCommandContext(), admin.LoadMaintenanceCommandContext(), diff --git a/src/internal/bot/command/general/pin.go b/src/internal/bot/command/general/pin.go new file mode 100644 index 00000000..a574ca48 --- /dev/null +++ b/src/internal/bot/command/general/pin.go @@ -0,0 +1,88 @@ +package general + +import ( + "time" + "unibot/internal" + + "github.com/bwmarrin/discordgo" +) + +func LoadPinCommandContext() *discordgo.ApplicationCommand { + perm := int64(discordgo.PermissionManageMessages) + dm := false + contexts := []discordgo.InteractionContextType{discordgo.InteractionContextGuild} + return &discordgo.ApplicationCommand{ + Name: "pin", + Description: "メッセージをピン留めします。", + DefaultMemberPermissions: &perm, + DMPermission: &dm, + Contexts: &contexts, + } +} + +func Pin(ctx *internal.BotContext, s *discordgo.Session, i *discordgo.InteractionCreate) { + config := ctx.Config + + if !hasPinPermission(s, i) { + replyPinError(s, i, config, "権限がありません", "この操作を実行する権限がありません。") + return + } + + showPinModal(s, i) +} + +func showPinModal(s *discordgo.Session, i *discordgo.InteractionCreate) { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseModal, + Data: &discordgo.InteractionResponseData{ + CustomID: "pin_message", + Title: "メッセージのピン留め", + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + discordgo.TextInput{ + CustomID: "message", + Label: "投稿内容", + Style: discordgo.TextInputParagraph, + Placeholder: "投稿内容を入力してください。すでにPinされたメッセージがある場合は上書きされます。", + Required: true, + }, + }}, + }, + }, + }) +} + +func hasPinPermission(s *discordgo.Session, i *discordgo.InteractionCreate) bool { + if i.Member == nil || i.Member.User == nil { + return false + } + perms, err := s.UserChannelPermissions(i.Member.User.ID, i.ChannelID) + if err != nil { + return false + } + return perms&discordgo.PermissionManageMessages != 0 +} + +func replyPinError(s *discordgo.Session, i *discordgo.InteractionCreate, config *internal.Config, title, description string) { + footer := &discordgo.MessageEmbedFooter{Text: "Requested by Unknown"} + if i.Member != nil { + footer.Text = "Requested by " + i.Member.DisplayName() + footer.IconURL = i.Member.AvatarURL("") + } else if i.User != nil { + footer.Text = "Requested by " + i.User.Username + footer.IconURL = i.User.AvatarURL("") + } + + _, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Embeds: &[]*discordgo.MessageEmbed{ + { + Title: title, + Description: description, + Color: config.Colors.Error, + Footer: footer, + Timestamp: time.Now().Format(time.RFC3339), + }, + }, + Flags: discordgo.MessageFlagsEphemeral, + }) +} diff --git a/src/internal/bot/command/general/pin_modal.go b/src/internal/bot/command/general/pin_modal.go new file mode 100644 index 00000000..29ae0eaf --- /dev/null +++ b/src/internal/bot/command/general/pin_modal.go @@ -0,0 +1,124 @@ +package general + +import ( + "unibot/internal" + "unibot/internal/model" + "unibot/internal/repository" + + "github.com/bwmarrin/discordgo" +) + +// HandlePinModalSubmit はピン留めモーダルの送信を処理する +func HandlePinModalSubmit(ctx *internal.BotContext, s *discordgo.Session, i *discordgo.InteractionCreate) bool { + data := i.ModalSubmitData() + if data.CustomID != "pin_message" { + return false + } + + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + + config := ctx.Config + if !hasPinPermission(s, i) { + replyPinError(s, i, config, "権限がありません", "この操作を実行する権限がありません。") + return true + } + + message := getPinModalValue(data, "message") + if message == "" { + replyPinError(s, i, config, "入力エラー", "投稿内容を入力してください。") + return true + } + + channel, err := s.State.Channel(i.ChannelID) + if err != nil { + channel, _ = s.Channel(i.ChannelID) + } + if channel == nil || channel.Type == discordgo.ChannelTypeDM || channel.Type == discordgo.ChannelTypeGroupDM { + replyPinError(s, i, config, "エラー", "このチャンネルではメッセージを送信できません。") + return true + } + + repo := repository.NewPinSettingRepository(ctx.DB) + existing, err := repo.GetByChannelID(i.ChannelID) + if err != nil { + replyPinError(s, i, config, "エラー", "ピン留めの取得に失敗しました。") + return true + } + if len(existing) > 0 { + replyPinError(s, i, config, "エラー", "このチャンネルには既にピン留めされたメッセージがあります。\n最初にそれを`/unpin`で解除してください。") + return true + } + + embed := &discordgo.MessageEmbed{ + Description: message, + Color: config.Colors.Success, + Footer: &discordgo.MessageEmbedFooter{ + Text: "Pinned Message", + }, + } + + sentMessage, err := s.ChannelMessageSendEmbed(i.ChannelID, embed) + if err != nil { + replyPinError(s, i, config, "エラー", "メッセージの送信に失敗しました。") + return true + } + + setting := &model.PinSetting{ + ID: i.ChannelID, + URL: sentMessage.ID, + Title: "Pinned Message", + Content: message, + GuildID: i.GuildID, + ChannelID: i.ChannelID, + } + + if err := repo.Create(setting); err != nil { + replyPinError(s, i, config, "エラー", "ピン留めの保存に失敗しました。") + return true + } + + content := "メッセージをピン留めしました: `" + message + "`" + _, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: &content, + }) + + return true +} + +func getPinModalValue(data discordgo.ModalSubmitInteractionData, customID string) string { + for _, comp := range data.Components { + switch row := comp.(type) { + case *discordgo.ActionsRow: + if value := getTextInputValue(row.Components, customID); value != "" { + return value + } + case discordgo.ActionsRow: + if value := getTextInputValue(row.Components, customID); value != "" { + return value + } + } + } + + return "" +} + +func getTextInputValue(components []discordgo.MessageComponent, customID string) string { + for _, component := range components { + switch input := component.(type) { + case *discordgo.TextInput: + if input.CustomID == customID { + return input.Value + } + case discordgo.TextInput: + if input.CustomID == customID { + return input.Value + } + } + } + return "" +} diff --git a/src/internal/bot/command/general/pin_select.go b/src/internal/bot/command/general/pin_select.go new file mode 100644 index 00000000..b2c4035a --- /dev/null +++ b/src/internal/bot/command/general/pin_select.go @@ -0,0 +1,106 @@ +package general + +import ( + "time" + "unibot/internal" + "unibot/internal/model" + "unibot/internal/repository" + + "github.com/bwmarrin/discordgo" +) + +func LoadPinSelectCommandContext() *discordgo.ApplicationCommand { + contexts := []discordgo.InteractionContextType{discordgo.InteractionContextGuild} + return &discordgo.ApplicationCommand{ + Name: "Pinするメッセージを選択", + Type: discordgo.MessageApplicationCommand, + Contexts: &contexts, + } +} + +func PinSelect(ctx *internal.BotContext, s *discordgo.Session, i *discordgo.InteractionCreate) { + config := ctx.Config + + if !hasPinPermission(s, i) { + replyPinError(s, i, config, "権限がありません", "この操作を実行する権限がありません。") + return + } + + data := i.ApplicationCommandData() + if data.Resolved == nil || data.Resolved.Messages == nil { + replyPinError(s, i, config, "エラー", "メッセージの取得に失敗しました。") + return + } + + targetMsg, ok := data.Resolved.Messages[data.TargetID] + if !ok || targetMsg == nil { + replyPinError(s, i, config, "エラー", "メッセージの取得に失敗しました。") + return + } + + if targetMsg.Author != nil && targetMsg.Author.Bot { + replyPinError(s, i, config, "エラー", "ボットのメッセージはピン留めできません。") + return + } + + channel, err := s.State.Channel(i.ChannelID) + if err != nil { + channel, _ = s.Channel(i.ChannelID) + } + if channel == nil || channel.Type == discordgo.ChannelTypeDM || channel.Type == discordgo.ChannelTypeGroupDM { + replyPinError(s, i, config, "エラー", "このチャンネルではメッセージをピン留めできません。") + return + } + + repo := repository.NewPinSettingRepository(ctx.DB) + settings, err := repo.GetByChannelID(i.ChannelID) + if err != nil { + replyPinError(s, i, config, "エラー", "ピン留めの取得に失敗しました。") + return + } + if len(settings) > 0 { + replyPinError(s, i, config, "エラー", "このチャンネルには既にピン留めされたメッセージがあります。\n最初にそれを`/unpin`で解除してください。") + return + } + + embed := &discordgo.MessageEmbed{ + Description: targetMsg.Content, + Color: config.Colors.Success, + Footer: &discordgo.MessageEmbedFooter{ + Text: "Pinned Message", + }, + } + + sentMessage, err := s.ChannelMessageSendEmbed(i.ChannelID, embed) + if err != nil { + replyPinError(s, i, config, "エラー", "メッセージの送信に失敗しました。") + return + } + + setting := &model.PinSetting{ + ID: i.ChannelID, + URL: sentMessage.ID, + Title: "Pinned Message", + Content: targetMsg.Content, + GuildID: i.GuildID, + ChannelID: i.ChannelID, + } + + err = repo.Create(setting) + if err != nil { + replyPinError(s, i, config, "エラー", "ピン留めの保存に失敗しました。") + return + } + + successEmbed := &discordgo.MessageEmbed{ + Title: "メッセージをピン留めしました", + Description: "このメッセージは今後ピン留めされます。\nファイルは保存されないのでご注意ください。", + Color: config.Colors.Success, + Timestamp: time.Now().Format(time.RFC3339), + } + + _, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Embeds: &[]*discordgo.MessageEmbed{successEmbed}, + Flags: discordgo.MessageFlagsEphemeral, + }) +} diff --git a/src/internal/bot/command/general/unpin.go b/src/internal/bot/command/general/unpin.go new file mode 100644 index 00000000..f7114e36 --- /dev/null +++ b/src/internal/bot/command/general/unpin.go @@ -0,0 +1,63 @@ +package general + +import ( + "time" + "unibot/internal" + "unibot/internal/repository" + + "github.com/bwmarrin/discordgo" +) + +func LoadUnpinCommandContext() *discordgo.ApplicationCommand { + perm := int64(discordgo.PermissionManageMessages) + dm := false + contexts := []discordgo.InteractionContextType{discordgo.InteractionContextGuild} + return &discordgo.ApplicationCommand{ + Name: "unpin", + Description: "ピン留めを解除します。", + DefaultMemberPermissions: &perm, + DMPermission: &dm, + Contexts: &contexts, + } +} + +func Unpin(ctx *internal.BotContext, s *discordgo.Session, i *discordgo.InteractionCreate) { + config := ctx.Config + + if !hasPinPermission(s, i) { + replyPinError(s, i, config, "権限がありません", "この操作を実行する権限がありません。") + return + } + + repo := repository.NewPinSettingRepository(ctx.DB) + settings, err := repo.GetByChannelID(i.ChannelID) + if err != nil { + replyPinError(s, i, config, "エラーが発生しました", "ピン留めの解除中にエラーが発生しました。") + return + } + if len(settings) == 0 { + content := "このチャンネルにはピン留めされたメッセージがありません。" + _, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: &content, + Flags: discordgo.MessageFlagsEphemeral, + }) + return + } + + err = repo.DeleteByChannelID(i.ChannelID) + if err != nil { + replyPinError(s, i, config, "エラーが発生しました", "ピン留めの解除中にエラーが発生しました。") + return + } + + successEmbed := &discordgo.MessageEmbed{ + Title: "ピン留めを解除しました", + Color: config.Colors.Success, + Timestamp: time.Now().Format(time.RFC3339), + } + + _, _ = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Embeds: &[]*discordgo.MessageEmbed{successEmbed}, + Flags: discordgo.MessageFlagsEphemeral, + }) +} diff --git a/src/internal/bot/command/registry.go b/src/internal/bot/command/registry.go index f46c8d11..e57f5d58 100644 --- a/src/internal/bot/command/registry.go +++ b/src/internal/bot/command/registry.go @@ -9,9 +9,12 @@ import ( ) var Handlers = map[string]func(*internal.BotContext, *discordgo.Session, *discordgo.InteractionCreate){ - "ping": general.Ping, - "about": general.About, - "maintenance": admin.Maintenance, - "tts": general.Tts, - "help": general.Help, -} + "ping": general.Ping, + "about": general.About, + "pin": general.Pin, + "unpin": general.Unpin, + "Pinするメッセージを選択": general.PinSelect, + "maintenance": admin.Maintenance, + "tts": general.Tts, + "help": general.Help, +} \ No newline at end of file diff --git a/src/internal/bot/handler/interaction.go b/src/internal/bot/handler/interaction.go index 97ea38fc..d69c69bd 100644 --- a/src/internal/bot/handler/interaction.go +++ b/src/internal/bot/handler/interaction.go @@ -4,6 +4,7 @@ import ( "strings" "unibot/internal" "unibot/internal/bot/command" + "unibot/internal/bot/command/general" "unibot/internal/bot/messageComponent" "github.com/bwmarrin/discordgo" @@ -16,6 +17,8 @@ func InteractionCreate(ctx *internal.BotContext) func(s *discordgo.Session, i *d handleApplicationCommand(ctx, s, i) case discordgo.InteractionMessageComponent: handleMessageComponent(ctx, s, i) + case discordgo.InteractionModalSubmit: + handleModalSubmit(ctx, s, i) } } } @@ -41,3 +44,9 @@ func handleMessageComponent(ctx *internal.BotContext, s *discordgo.Session, i *d } } +func handleModalSubmit(ctx *internal.BotContext, s *discordgo.Session, i *discordgo.InteractionCreate) { + if general.HandlePinModalSubmit(ctx, s, i) { + return + } +} + diff --git a/src/internal/bot/handler/messageCreate.go b/src/internal/bot/handler/messageCreate.go index 460a9360..abb78844 100644 --- a/src/internal/bot/handler/messageCreate.go +++ b/src/internal/bot/handler/messageCreate.go @@ -3,6 +3,7 @@ package handler import ( "log" "regexp" + "strings" "unibot/internal" "unibot/internal/bot/voice" "unibot/internal/repository" @@ -14,13 +15,21 @@ import ( func MessageCreate(ctx *internal.BotContext) func(s *discordgo.Session, r *discordgo.MessageCreate) { return func(s *discordgo.Session, r *discordgo.MessageCreate) { - // Ignore bot itself - if r.Author.ID == s.State.User.ID { + // Ignore DM + if r.GuildID == "" { return } - // Ignore DM - if r.GuildID == "" { + // Authorが存在しないメッセージは無視 + if r.Author == nil { + return + } + + // ----- Pin ----- + resendPinnedMessage(ctx, s, r) + + // Ignore bot itself + if r.Author.ID == s.State.User.ID { return } @@ -82,6 +91,50 @@ func MessageCreate(ctx *internal.BotContext) func(s *discordgo.Session, r *disco } } +func resendPinnedMessage(ctx *internal.BotContext, s *discordgo.Session, r *discordgo.MessageCreate) { + // 自分のピン留めメッセージの場合は無視 + if r.Author != nil && r.Author.ID == s.State.User.ID { + if len(r.Embeds) == 0 { + return + } + if r.Embeds[0].Footer != nil && strings.Contains(r.Embeds[0].Footer.Text, "Pinned Message") { + return + } + } + + repo := repository.NewPinSettingRepository(ctx.DB) + settings, err := repo.GetByChannelID(r.ChannelID) + if err != nil || len(settings) == 0 { + return + } + + setting := settings[0] + if setting.Content == "" { + return + } + + if setting.URL != "" { + _ = s.ChannelMessageDelete(r.ChannelID, setting.URL) + } + + embed := &discordgo.MessageEmbed{ + Description: setting.Content, + Color: ctx.Config.Colors.Success, + Footer: &discordgo.MessageEmbedFooter{ + Text: "Pinned Message", + }, + } + + sentMessage, err := s.ChannelMessageSendEmbed(r.ChannelID, embed) + if err != nil { + return + } + + setting.URL = sentMessage.ID + setting.Title = "Pinned Message" + _ = repo.Update(setting) +} + // 正規表現パターン var ( codeBlockRegex = regexp.MustCompile("(?s)```(\\w*)\\n.*?```") diff --git a/src/internal/repository/pin_setting.go b/src/internal/repository/pin_setting.go index 954ef2b3..015bdba6 100644 --- a/src/internal/repository/pin_setting.go +++ b/src/internal/repository/pin_setting.go @@ -21,6 +21,9 @@ func NewPinSettingRepository(db *gorm.DB) *PinSettingRepository { // PinSettingを作成する関数 func (r *PinSettingRepository) Create(pinSetting *model.PinSetting) error { + if err := r.ensureGuild(pinSetting); err != nil { + return err + } return r.db.Create(pinSetting).Error } @@ -63,6 +66,9 @@ func (r *PinSettingRepository) List() ([]*model.PinSetting, error) { // PinSettingを更新する関数 func (r *PinSettingRepository) Update(pinSetting *model.PinSetting) error { + if err := r.ensureGuild(pinSetting); err != nil { + return err + } return r.db.Save(pinSetting).Error } @@ -70,3 +76,16 @@ func (r *PinSettingRepository) Update(pinSetting *model.PinSetting) error { func (r *PinSettingRepository) Delete(id string) error { return r.db.Delete(&model.PinSetting{}, "id = ?", id).Error } + +// ChannelIDでPinSettingを削除する関数 +func (r *PinSettingRepository) DeleteByChannelID(channelID string) error { + return r.db.Delete(&model.PinSetting{}, "channel_id = ?", channelID).Error +} + +func (r *PinSettingRepository) ensureGuild(pinSetting *model.PinSetting) error { + if pinSetting == nil || pinSetting.GuildID == "" { + return nil + } + guild := model.Guild{DiscordID: pinSetting.GuildID} + return r.db.FirstOrCreate(&guild).Error +} diff --git a/src/internal/repository/pin_setting_test.go b/src/internal/repository/pin_setting_test.go index 122bd5c0..30563a82 100644 --- a/src/internal/repository/pin_setting_test.go +++ b/src/internal/repository/pin_setting_test.go @@ -106,3 +106,29 @@ func TestPinSettingUpdateNonexistent(t *testing.T) { retrieved, _ := repo.GetByID("nonexistent") assert.NotNil(t, retrieved) } + +func TestPinSettingDeleteByChannelID(t *testing.T) { + db := setupTestDB(t) + repo := repository.NewPinSettingRepository(db) + + pin1 := &model.PinSetting{ID: "ch1", GuildID: "guild1", ChannelID: "ch1"} + pin2 := &model.PinSetting{ID: "ch2", GuildID: "guild1", ChannelID: "ch2"} + db.Create(pin1) + db.Create(pin2) + + err := repo.DeleteByChannelID("ch1") + assert.NoError(t, err) + + deleted, _ := repo.GetByID("ch1") + remaining, _ := repo.GetByID("ch2") + assert.Nil(t, deleted) + assert.NotNil(t, remaining) +} + +func TestPinSettingDeleteByChannelIDNonexistent(t *testing.T) { + db := setupTestDB(t) + repo := repository.NewPinSettingRepository(db) + + err := repo.DeleteByChannelID("missing") + assert.NoError(t, err) +}