diff --git a/src/cmd/bot/main.go b/src/cmd/bot/main.go index 64a6e03a..df765db8 100644 --- a/src/cmd/bot/main.go +++ b/src/cmd/bot/main.go @@ -14,6 +14,7 @@ import ( "unibot/internal/bot/command" "unibot/internal/bot/handler" "unibot/internal/db" + "unibot/internal/scheduler" ) func main() { @@ -57,6 +58,10 @@ func main() { } defer dg.Close() + schedulerManager := scheduler.NewManager(ctx, dg) + schedulerManager.Start() + defer schedulerManager.Stop() + log.Println("Bot is running...") // Register commands diff --git a/src/go.mod b/src/go.mod index 49802796..6ec56fd3 100644 --- a/src/go.mod +++ b/src/go.mod @@ -24,6 +24,7 @@ require ( github.com/jinzhu/now v1.1.5 // indirect github.com/mattn/go-sqlite3 v1.14.22 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect golang.org/x/crypto v0.47.0 // indirect golang.org/x/sync v0.19.0 // indirect diff --git a/src/go.sum b/src/go.sum index a8925b17..0b5e94ca 100644 --- a/src/go.sum +++ b/src/go.sum @@ -102,6 +102,8 @@ github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxU github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= diff --git a/src/internal/bot/command/commands.go b/src/internal/bot/command/commands.go index da2f1256..4471ba54 100644 --- a/src/internal/bot/command/commands.go +++ b/src/internal/bot/command/commands.go @@ -10,7 +10,11 @@ import ( var Commands = []*discordgo.ApplicationCommand{ general.LoadPingCommandContext(), general.LoadAboutCommandContext(), + general.LoadPinCommandContext(), + general.LoadUnpinCommandContext(), + general.LoadPinSelectCommandContext(), general.LoadTtsCommandContext(), + general.LoadScheduleCommandContext(), general.LoadHelpCommandContext(), admin.LoadMaintenanceCommandContext(), } diff --git a/src/internal/bot/command/general/help.go b/src/internal/bot/command/general/help.go index b79ba4a2..9a783886 100644 --- a/src/internal/bot/command/general/help.go +++ b/src/internal/bot/command/general/help.go @@ -153,7 +153,6 @@ func Help(ctx *internal.BotContext, s *discordgo.Session, i *discordgo.Interacti }, Timestamp: time.Now().Format(time.RFC3339), } - _, err := s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ Embeds: &[]*discordgo.MessageEmbed{responseEmbed}, }) diff --git a/src/internal/bot/command/general/pin.go b/src/internal/bot/command/general/pin.go new file mode 100644 index 00000000..62572132 --- /dev/null +++ b/src/internal/bot/command/general/pin.go @@ -0,0 +1,91 @@ +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.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + 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..9316d562 --- /dev/null +++ b/src/internal/bot/command/general/pin_modal.go @@ -0,0 +1,122 @@ +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 + } + + 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 + } + + 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 + } + + repo := repository.NewPinSettingRepository(ctx.DB) + setting := &model.PinSetting{ + ID: i.ChannelID, + URL: sentMessage.ID, + Title: "Pinned Message", + Content: message, + GuildID: i.GuildID, + ChannelID: i.ChannelID, + } + + existing, err := repo.GetByChannelID(i.ChannelID) + if err != nil { + replyPinError(s, i, config, "エラー", "ピン留めの保存に失敗しました。") + return true + } + if len(existing) == 0 { + if err := repo.Create(setting); err != nil { + replyPinError(s, i, config, "エラー", "ピン留めの保存に失敗しました。") + return true + } + } else { + if err := repo.Update(setting); err != nil { + replyPinError(s, i, config, "エラー", "ピン留めの保存に失敗しました。") + return true + } + } + + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: "メッセージをピン留めしました: `" + message + "`", + Flags: discordgo.MessageFlagsEphemeral, + }, + }) + + 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..09ee07be --- /dev/null +++ b/src/internal/bot/command/general/pin_select.go @@ -0,0 +1,109 @@ +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.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{successEmbed}, + Flags: discordgo.MessageFlagsEphemeral, + }, + }) +} diff --git a/src/internal/bot/command/general/schedule.go b/src/internal/bot/command/general/schedule.go new file mode 100644 index 00000000..18a5719d --- /dev/null +++ b/src/internal/bot/command/general/schedule.go @@ -0,0 +1,94 @@ +package general + +import ( + "time" + "unibot/internal" + schedulecmd "unibot/internal/bot/command/general/schedule" + + "github.com/bwmarrin/discordgo" +) + +func LoadScheduleCommandContext() *discordgo.ApplicationCommand { + perm := int64(discordgo.PermissionManageMessages) + return &discordgo.ApplicationCommand{ + Name: "schedule", + Description: "予約投稿を管理します", + DefaultMemberPermissions: &perm, + Options: []*discordgo.ApplicationCommandOption{ + schedulecmd.LoadSetCommandContext(), + schedulecmd.LoadListCommandContext(), + schedulecmd.LoadRemoveCommandContext(), + }, + } +} + +var scheduleHandler = map[string]func(ctx *internal.BotContext, s *discordgo.Session, i *discordgo.InteractionCreate){ + "set": schedulecmd.Set, + "list": schedulecmd.List, + "remove": schedulecmd.Remove, +} + +func Schedule(ctx *internal.BotContext, s *discordgo.Session, i *discordgo.InteractionCreate) { + config := ctx.Config + + if i.GuildID == "" { + _ = schedulecmd.RespondEdit(s, i, &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "エラー", + Description: "このコマンドはサーバー内でのみ使用できます。", + Color: config.Colors.Error, + Footer: &discordgo.MessageEmbedFooter{ + Text: "Requested by " + i.Member.DisplayName(), + IconURL: i.Member.AvatarURL(""), + }, + Timestamp: time.Now().Format(time.RFC3339), + }, + }, + Flags: discordgo.MessageFlagsEphemeral, + }) + return + } + + perms, err := s.UserChannelPermissions(i.Member.User.ID, i.ChannelID) + if err != nil || perms&discordgo.PermissionManageMessages == 0 { + _ = schedulecmd.RespondEdit(s, i, &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "エラー", + Description: "このコマンドを実行する権限がありません。", + Color: config.Colors.Error, + Footer: &discordgo.MessageEmbedFooter{ + Text: "Requested by " + i.Member.DisplayName(), + IconURL: i.Member.AvatarURL(""), + }, + Timestamp: time.Now().Format(time.RFC3339), + }, + }, + Flags: discordgo.MessageFlagsEphemeral, + }) + return + } + + subCommand := i.ApplicationCommandData().Options[0] + if handler, exists := scheduleHandler[subCommand.Name]; exists { + handler(ctx, s, i) + return + } + + _ = schedulecmd.RespondEdit(s, i, &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "エラー", + Description: "不明なサブコマンドです。", + Color: config.Colors.Error, + Footer: &discordgo.MessageEmbedFooter{ + Text: "Requested by " + i.Member.DisplayName(), + IconURL: i.Member.AvatarURL(""), + }, + Timestamp: time.Now().Format(time.RFC3339), + }, + }, + Flags: discordgo.MessageFlagsEphemeral, + }) +} diff --git a/src/internal/bot/command/general/schedule/cron.go b/src/internal/bot/command/general/schedule/cron.go new file mode 100644 index 00000000..8c485cad --- /dev/null +++ b/src/internal/bot/command/general/schedule/cron.go @@ -0,0 +1,379 @@ +package schedule + +import ( + "errors" + "fmt" + "regexp" + "strconv" + "strings" + "time" + "unibot/internal/scheduler" +) + +type scheduleSpec interface { + Next(after time.Time) time.Time +} + +type intervalUnit int + +const ( + unitMinutes intervalUnit = iota + unitHours + unitDays + unitWeeks + unitMonths + unitYears +) + +type intervalSchedule struct { + interval int + unit intervalUnit +} + +type dailyAtSchedule struct { + hour int + min int +} + +type weeklyAtSchedule struct { + weekday time.Weekday + hour int + min int +} + +func (s intervalSchedule) Next(after time.Time) time.Time { + base := normalizeTime(after) + var next time.Time + + switch s.unit { + case unitMinutes: + next = base.Add(time.Duration(s.interval) * time.Minute) + case unitHours: + next = base.Add(time.Duration(s.interval) * time.Hour) + case unitDays: + next = base.AddDate(0, 0, s.interval) + case unitWeeks: + next = base.AddDate(0, 0, 7*s.interval) + case unitMonths: + next = base.AddDate(0, s.interval, 0) + case unitYears: + next = base.AddDate(s.interval, 0, 0) + } + + if !next.After(after) { + next = next.Add(time.Minute) + } + + return next +} + +func (s dailyAtSchedule) Next(after time.Time) time.Time { + base := after.In(scheduler.JST()) + candidate := time.Date(base.Year(), base.Month(), base.Day(), s.hour, s.min, 0, 0, base.Location()) + if !candidate.After(after) { + candidate = candidate.AddDate(0, 0, 1) + } + return candidate +} + +func (s weeklyAtSchedule) Next(after time.Time) time.Time { + base := after.In(scheduler.JST()) + daysUntil := (int(s.weekday) - int(base.Weekday()) + 7) % 7 + candidate := time.Date(base.Year(), base.Month(), base.Day(), s.hour, s.min, 0, 0, base.Location()).AddDate(0, 0, daysUntil) + if !candidate.After(after) { + candidate = candidate.AddDate(0, 0, 7) + } + return candidate +} + +func normalizeTime(t time.Time) time.Time { + loc := scheduler.JST() + return time.Date(t.In(loc).Year(), t.In(loc).Month(), t.In(loc).Day(), t.In(loc).Hour(), t.In(loc).Minute(), 0, 0, loc) +} + +// convertToCron は自然言語の繰り返し指定をcronに変換する +func convertToCron(input string) (string, error) { + text := preprocessScheduleText(input) + spec, err := parseScheduleText(text) + if err != nil { + return "", err + } + + now := time.Now().In(scheduler.JST()) + first := spec.Next(now) + second := spec.Next(first) + + diffMinutes := int(second.Sub(first).Minutes()) + if diffMinutes <= 0 { + return "", errors.New("invalid schedule") + } + + min := first.Minute() + hour := first.Hour() + date := first.Day() + month := int(first.Month()) + weekDay := int(first.Weekday()) + + switch { + case diffMinutes < 60: + return fmt.Sprintf("*/%d * * * *", diffMinutes), nil + case diffMinutes%60 == 0 && diffMinutes < 1440: + hours := diffMinutes / 60 + return fmt.Sprintf("%d */%d * * *", min, hours), nil + case diffMinutes >= 1440 && diffMinutes < 10080: + return fmt.Sprintf("%d %d * * *", min, hour), nil + case diffMinutes >= 10080 && diffMinutes < 40320: + return fmt.Sprintf("%d %d * * %d", min, hour, weekDay), nil + case diffMinutes >= 40320 && diffMinutes < 525600: + return fmt.Sprintf("%d %d %d * *", min, hour, date), nil + case diffMinutes >= 525600: + return fmt.Sprintf("%d %d %d %d *", min, hour, date, month), nil + default: + return "", errors.New("invalid schedule") + } +} + +func preprocessScheduleText(input string) string { + text := strings.TrimSpace(input) + if text == "" { + return "" + } + + reEveryDay := regexp.MustCompile(`(?i)^\s*every\s+day\s*$`) + if reEveryDay.MatchString(text) { + text = "every day at 9:00 am" + } + + reEveryDayAny := regexp.MustCompile(`(?i)\bevery\s+day\b`) + if reEveryDayAny.MatchString(text) && !reEveryDay.MatchString(text) { + text = strings.TrimSpace(reEveryDayAny.ReplaceAllString(text, "")) + } + + reZeroHour := regexp.MustCompile(`\b0:([0-5][0-9])\b`) + text = reZeroHour.ReplaceAllString(text, "12:$1") + + reTwelveWithoutAmPm := regexp.MustCompile(`\b12:([0-5][0-9])\b(?!\s?(am|pm))`) + text = reTwelveWithoutAmPm.ReplaceAllString(text, "12:$1 am") + + re24Hour := regexp.MustCompile(`\b([1][3-9]|2[0-3]):([0-5][0-9])\b`) + text = re24Hour.ReplaceAllStringFunc(text, func(match string) string { + parts := strings.Split(match, ":") + if len(parts) != 2 { + return match + } + hour, err := strconv.Atoi(parts[0]) + if err != nil { + return match + } + minute := parts[1] + ampmHour := hour - 12 + period := "pm" + if hour < 12 { + ampmHour = hour + period = "am" + } + return fmt.Sprintf("%d:%s %s", ampmHour, minute, period) + }) + + return strings.TrimSpace(text) +} + +func parseScheduleText(text string) (scheduleSpec, error) { + if text == "" { + return nil, errors.New("empty schedule") + } + + reAt := regexp.MustCompile(`(?i)^at\s+([0-9]{1,2}:[0-9]{2}(?:\s*(?:am|pm))?)$`) + if match := reAt.FindStringSubmatch(text); match != nil { + hour, min, err := parseTime(match[1]) + if err != nil { + return nil, err + } + return dailyAtSchedule{hour: hour, min: min}, nil + } + + reDaily := regexp.MustCompile(`(?i)^every\s+day\s+at\s+([0-9]{1,2}:[0-9]{2}(?:\s*(?:am|pm))?)$`) + if match := reDaily.FindStringSubmatch(text); match != nil { + hour, min, err := parseTime(match[1]) + if err != nil { + return nil, err + } + return dailyAtSchedule{hour: hour, min: min}, nil + } + + reWeekly := regexp.MustCompile(`(?i)^every\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)\s+at\s+([0-9]{1,2}:[0-9]{2}(?:\s*(?:am|pm))?)$`) + if match := reWeekly.FindStringSubmatch(text); match != nil { + weekday := parseWeekday(match[1]) + hour, min, err := parseTime(match[2]) + if err != nil { + return nil, err + } + return weeklyAtSchedule{weekday: weekday, hour: hour, min: min}, nil + } + + reEvery := regexp.MustCompile(`(?i)^every\s+(\d+)\s+(minute|minutes|mins|hour|hours|day|days|week|weeks|month|months|year|years)$`) + if match := reEvery.FindStringSubmatch(text); match != nil { + interval, err := strconv.Atoi(match[1]) + if err != nil || interval <= 0 { + return nil, errors.New("invalid interval") + } + + switch strings.ToLower(match[2]) { + case "minute", "minutes", "mins": + return intervalSchedule{interval: interval, unit: unitMinutes}, nil + case "hour", "hours": + return intervalSchedule{interval: interval, unit: unitHours}, nil + case "day", "days": + return intervalSchedule{interval: interval, unit: unitDays}, nil + case "week", "weeks": + return intervalSchedule{interval: interval, unit: unitWeeks}, nil + case "month", "months": + return intervalSchedule{interval: interval, unit: unitMonths}, nil + case "year", "years": + return intervalSchedule{interval: interval, unit: unitYears}, nil + } + } + + return nil, errors.New("invalid schedule format") +} + +func parseTime(text string) (int, int, error) { + re := regexp.MustCompile(`(?i)^(\d{1,2}):(\d{2})\s*(am|pm)?$`) + match := re.FindStringSubmatch(strings.TrimSpace(text)) + if match == nil { + return 0, 0, errors.New("invalid time") + } + + hour, err := strconv.Atoi(match[1]) + if err != nil { + return 0, 0, errors.New("invalid time") + } + + min, err := strconv.Atoi(match[2]) + if err != nil { + return 0, 0, errors.New("invalid time") + } + + if min < 0 || min > 59 { + return 0, 0, errors.New("invalid time") + } + + ampm := strings.ToLower(match[3]) + if ampm != "" { + if hour < 1 || hour > 12 { + return 0, 0, errors.New("invalid time") + } + if ampm == "am" { + if hour == 12 { + hour = 0 + } + } + if ampm == "pm" { + if hour != 12 { + hour += 12 + } + } + } else { + if hour < 0 || hour > 23 { + return 0, 0, errors.New("invalid time") + } + } + + return hour, min, nil +} + +func parseWeekday(text string) time.Weekday { + switch strings.ToLower(text) { + case "monday": + return time.Monday + case "tuesday": + return time.Tuesday + case "wednesday": + return time.Wednesday + case "thursday": + return time.Thursday + case "friday": + return time.Friday + case "saturday": + return time.Saturday + case "sunday": + return time.Sunday + default: + return time.Sunday + } +} + +func describeCron(cronText string) string { + fields := strings.Fields(cronText) + if len(fields) != 5 { + return cronText + } + + min := fields[0] + hour := fields[1] + day := fields[2] + month := fields[3] + week := fields[4] + + if strings.HasPrefix(min, "*/") && hour == "*" && day == "*" && month == "*" && week == "*" { + return fmt.Sprintf("Every %s minutes", strings.TrimPrefix(min, "*/")) + } + + if strings.HasPrefix(hour, "*/") && day == "*" && month == "*" && week == "*" { + return fmt.Sprintf("Every %s hours at minute %s", strings.TrimPrefix(hour, "*/"), min) + } + + if day == "*" && month == "*" && week == "*" { + return fmt.Sprintf("At %s:%s every day", padHour(hour), padMinute(min)) + } + + if day == "*" && month == "*" && week != "*" { + return fmt.Sprintf("At %s:%s, only on %s", padHour(hour), padMinute(min), weekDayName(week)) + } + + if day != "*" && month == "*" && week == "*" { + return fmt.Sprintf("At %s:%s, on day %s of the month", padHour(hour), padMinute(min), day) + } + + if day != "*" && month != "*" && week == "*" { + return fmt.Sprintf("At %s:%s, on day %s of month %s", padHour(hour), padMinute(min), day, month) + } + + return cronText +} + +func padHour(hour string) string { + if len(hour) == 1 { + return "0" + hour + } + return hour +} + +func padMinute(min string) string { + if len(min) == 1 { + return "0" + min + } + return min +} + +func weekDayName(week string) string { + week = strings.TrimSpace(week) + switch week { + case "0", "7": + return "Sunday" + case "1": + return "Monday" + case "2": + return "Tuesday" + case "3": + return "Wednesday" + case "4": + return "Thursday" + case "5": + return "Friday" + case "6": + return "Saturday" + default: + return week + } +} diff --git a/src/internal/bot/command/general/schedule/list.go b/src/internal/bot/command/general/schedule/list.go new file mode 100644 index 00000000..6e4da432 --- /dev/null +++ b/src/internal/bot/command/general/schedule/list.go @@ -0,0 +1,85 @@ +package schedule + +import ( + "fmt" + "time" + "unibot/internal" + "unibot/internal/repository" + + "github.com/bwmarrin/discordgo" +) + +func LoadListCommandContext() *discordgo.ApplicationCommandOption { + return &discordgo.ApplicationCommandOption{ + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "list", + Description: "予約投稿の一覧を表示します", + } +} + +func List(ctx *internal.BotContext, s *discordgo.Session, i *discordgo.InteractionCreate) { + config := ctx.Config + repo := repository.NewScheduleSettingRepository(ctx.DB) + + settings, err := repo.GetByChannelID(i.ChannelID) + if err != nil { + _ = RespondEdit(s, i, &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "エラー", + Description: "スケジュールの取得に失敗しました。", + Color: config.Colors.Error, + Footer: &discordgo.MessageEmbedFooter{ + Text: "Requested by " + i.Member.DisplayName(), + IconURL: i.Member.AvatarURL(""), + }, + Timestamp: time.Now().Format(time.RFC3339), + }, + }, + Flags: discordgo.MessageFlagsEphemeral, + }) + return + } + + if len(settings) == 0 { + _ = RespondEdit(s, i, &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "予約投稿一覧", + Description: "予約投稿はまだありません。", + Color: config.Colors.Success, + Timestamp: time.Now().Format(time.RFC3339), + }, + }, + Flags: discordgo.MessageFlagsEphemeral, + }) + return + } + + embed := &discordgo.MessageEmbed{ + Title: "予約投稿一覧", + Color: config.Colors.Success, + Timestamp: time.Now().Format(time.RFC3339), + Footer: &discordgo.MessageEmbedFooter{ + Text: "Requested by " + i.Member.DisplayName(), + IconURL: i.Member.AvatarURL(""), + }, + } + + for _, setting := range settings { + repeatText := "いいえ" + if setting.Cron != "" { + repeatText = describeCron(setting.Cron) + } + + embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{ + Name: fmt.Sprintf("ジョブID: %s", setting.ID), + Value: fmt.Sprintf("メッセージ: %s\n次回実行予定: \n繰り返し: %s", setting.Content, setting.NextRunAt, repeatText), + }) + } + + _ = RespondEdit(s, i, &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{embed}, + Flags: discordgo.MessageFlagsEphemeral, + }) +} diff --git a/src/internal/bot/command/general/schedule/modals.go b/src/internal/bot/command/general/schedule/modals.go new file mode 100644 index 00000000..c9914f5f --- /dev/null +++ b/src/internal/bot/command/general/schedule/modals.go @@ -0,0 +1,223 @@ +package schedule + +import ( + "fmt" + "strings" + "time" + "unibot/internal" + "unibot/internal/model" + "unibot/internal/repository" + "unibot/internal/scheduler" + + "github.com/bwmarrin/discordgo" +) + +// モーダル送信を処理する +func HandleModalSubmit(ctx *internal.BotContext, s *discordgo.Session, i *discordgo.InteractionCreate) bool { + data := i.ModalSubmitData() + + switch data.CustomID { + case "schedule_create_onetime": + deferModalResponse(s, i) + handleCreateOnetime(ctx, s, i, data) + return true + case "schedule_create_repeat": + deferModalResponse(s, i) + handleCreateRepeat(ctx, s, i, data) + return true + default: + return false + } +} + +func deferModalResponse(s *discordgo.Session, i *discordgo.InteractionCreate) { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseDeferredChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Flags: discordgo.MessageFlagsEphemeral, + }, + }) +} + +func handleCreateOnetime(ctx *internal.BotContext, s *discordgo.Session, i *discordgo.InteractionCreate, data discordgo.ModalSubmitInteractionData) { + config := ctx.Config + + if !hasManageMessagesPermission(s, i) { + replyPermissionError(s, i, config) + return + } + + message := getTextInputValue(data, "message") + timeText := getTextInputValue(data, "time") + + if message == "" || timeText == "" { + replyError(s, i, config, "入力エラー", "投稿内容と投稿時間を入力してください。") + return + } + + jst := scheduler.JST() + scheduledTime, err := time.ParseInLocation("2006-01-02 15:04", strings.TrimSpace(timeText), jst) + if err != nil { + replyError(s, i, config, "時間の形式が正しくありません。", "YYYY-MM-DD HH:mm の形式で入力してください。") + return + } + + if scheduledTime.Before(time.Now().In(jst)) { + replyError(s, i, config, "過去の日時は指定できません。", "未来の日時を入力してください。") + return + } + + setting := &model.ScheduleSetting{ + ID: i.ID, + ChannelID: i.ChannelID, + Content: message, + NextRunAt: scheduledTime.Unix(), + Cron: "", + GuildID: i.GuildID, + AuthorID: i.Member.User.ID, + } + + repo := repository.NewScheduleSettingRepository(ctx.DB) + if err := repo.Create(setting); err != nil { + replyError(s, i, config, "エラー", "スケジュールの作成に失敗しました。") + return + } + + successEmbed := &discordgo.MessageEmbed{ + Title: "予約投稿を作成しました", + Description: fmt.Sprintf("メッセージをに送信するようにスケジュールしました。\nジョブID: %s", scheduledTime.Unix(), i.ID), + Color: config.Colors.Success, + Timestamp: time.Now().Format(time.RFC3339), + } + + _ = RespondEdit(s, i, &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{successEmbed}, + Flags: discordgo.MessageFlagsEphemeral, + }) +} + +func handleCreateRepeat(ctx *internal.BotContext, s *discordgo.Session, i *discordgo.InteractionCreate, data discordgo.ModalSubmitInteractionData) { + config := ctx.Config + + if !hasManageMessagesPermission(s, i) { + replyPermissionError(s, i, config) + return + } + + message := getTextInputValue(data, "message") + inputText := getTextInputValue(data, "time") + + if message == "" || inputText == "" { + replyError(s, i, config, "入力エラー", "投稿内容と時間を入力してください。") + return + } + + jst := scheduler.JST() + cronText, err := convertToCron(strings.TrimSpace(inputText)) + if err != nil { + replyError(s, i, config, "時間の形式が不正です。", "時間の形式が正しくありません。もう一度確認してください。") + return + } + + nextRunAt, err := scheduler.NextRunAtFromCron(cronText, time.Now().In(jst)) + if err != nil { + replyError(s, i, config, "時間の形式が不正です。", "時間の形式が正しくありません。もう一度確認してください。") + return + } + + setting := &model.ScheduleSetting{ + ID: i.ID, + ChannelID: i.ChannelID, + Content: message, + NextRunAt: nextRunAt.Unix(), + Cron: strings.TrimSpace(cronText), + GuildID: i.GuildID, + AuthorID: i.Member.User.ID, + } + + repo := repository.NewScheduleSettingRepository(ctx.DB) + if err := repo.Create(setting); err != nil { + replyError(s, i, config, "エラー", "スケジュールの作成に失敗しました。") + return + } + + successEmbed := &discordgo.MessageEmbed{ + Title: "予約投稿を作成しました", + Description: fmt.Sprintf("メッセージを%sに送信するようにスケジュールしました。\nジョブID: %s", strings.TrimSpace(inputText), i.ID), + Color: config.Colors.Success, + Timestamp: time.Now().Format(time.RFC3339), + } + + _ = RespondEdit(s, i, &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{successEmbed}, + Flags: discordgo.MessageFlagsEphemeral, + }) +} + +func getTextInputValue(data discordgo.ModalSubmitInteractionData, customID string) string { + for _, comp := range data.Components { + switch row := comp.(type) { + case *discordgo.ActionsRow: + for _, component := range row.Components { + if input, ok := component.(*discordgo.TextInput); ok { + if input.CustomID == customID { + return input.Value + } + } + if input, ok := component.(discordgo.TextInput); ok { + if input.CustomID == customID { + return input.Value + } + } + } + case discordgo.ActionsRow: + for _, component := range row.Components { + if input, ok := component.(*discordgo.TextInput); ok { + if input.CustomID == customID { + return input.Value + } + } + if input, ok := component.(discordgo.TextInput); ok { + if input.CustomID == customID { + return input.Value + } + } + } + } + } + + return "" +} + +func hasManageMessagesPermission(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 replyPermissionError(s *discordgo.Session, i *discordgo.InteractionCreate, config *internal.Config) { + replyError(s, i, config, "エラー", "この操作を実行する権限がありません。") +} + +func replyError(s *discordgo.Session, i *discordgo.InteractionCreate, config *internal.Config, title, description string) { + _ = RespondEdit(s, i, &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: title, + Description: description, + Color: config.Colors.Error, + Footer: &discordgo.MessageEmbedFooter{ + Text: "Requested by " + i.Member.DisplayName(), + IconURL: i.Member.AvatarURL(""), + }, + Timestamp: time.Now().Format(time.RFC3339), + }, + }, + Flags: discordgo.MessageFlagsEphemeral, + }) +} diff --git a/src/internal/bot/command/general/schedule/remove.go b/src/internal/bot/command/general/schedule/remove.go new file mode 100644 index 00000000..b0c98912 --- /dev/null +++ b/src/internal/bot/command/general/schedule/remove.go @@ -0,0 +1,128 @@ +package schedule + +import ( + "time" + "unibot/internal" + "unibot/internal/repository" + + "github.com/bwmarrin/discordgo" +) + +func LoadRemoveCommandContext() *discordgo.ApplicationCommandOption { + return &discordgo.ApplicationCommandOption{ + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "remove", + Description: "予約投稿を削除します", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "jobid", + Description: "削除する予約投稿のジョブID", + Required: true, + }, + }, + } +} + +func Remove(ctx *internal.BotContext, s *discordgo.Session, i *discordgo.InteractionCreate) { + config := ctx.Config + options := i.ApplicationCommandData().Options[0].Options + + var jobID string + for _, opt := range options { + if opt.Name == "jobid" { + jobID = opt.StringValue() + } + } + + if jobID == "" { + _ = RespondEdit(s, i, &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "エラー", + Description: "削除するジョブIDを指定してください。", + Color: config.Colors.Error, + Footer: &discordgo.MessageEmbedFooter{ + Text: "Requested by " + i.Member.DisplayName(), + IconURL: i.Member.AvatarURL(""), + }, + Timestamp: time.Now().Format(time.RFC3339), + }, + }, + Flags: discordgo.MessageFlagsEphemeral, + }) + return + } + + repo := repository.NewScheduleSettingRepository(ctx.DB) + setting, err := repo.GetByID(jobID) + if err != nil || setting == nil { + _ = RespondEdit(s, i, &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "エラー", + Description: "指定されたジョブIDが見つかりません。", + Color: config.Colors.Error, + Footer: &discordgo.MessageEmbedFooter{ + Text: "Requested by " + i.Member.DisplayName(), + IconURL: i.Member.AvatarURL(""), + }, + Timestamp: time.Now().Format(time.RFC3339), + }, + }, + Flags: discordgo.MessageFlagsEphemeral, + }) + return + } + + if setting.GuildID != i.GuildID { + _ = RespondEdit(s, i, &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "エラー", + Description: "このジョブを削除する権限がありません。", + Color: config.Colors.Error, + Footer: &discordgo.MessageEmbedFooter{ + Text: "Requested by " + i.Member.DisplayName(), + IconURL: i.Member.AvatarURL(""), + }, + Timestamp: time.Now().Format(time.RFC3339), + }, + }, + Flags: discordgo.MessageFlagsEphemeral, + }) + return + } + + err = repo.DeleteByID(jobID) + if err != nil { + _ = RespondEdit(s, i, &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "エラー", + Description: "スケジュールの削除に失敗しました。", + Color: config.Colors.Error, + Footer: &discordgo.MessageEmbedFooter{ + Text: "Requested by " + i.Member.DisplayName(), + IconURL: i.Member.AvatarURL(""), + }, + Timestamp: time.Now().Format(time.RFC3339), + }, + }, + Flags: discordgo.MessageFlagsEphemeral, + }) + return + } + + successEmbed := &discordgo.MessageEmbed{ + Title: "予約投稿を削除しました", + Description: "指定された予約投稿を削除しました。", + Color: config.Colors.Success, + Timestamp: time.Now().Format(time.RFC3339), + } + + _ = RespondEdit(s, i, &discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{successEmbed}, + Flags: discordgo.MessageFlagsEphemeral, + }) +} diff --git a/src/internal/bot/command/general/schedule/response.go b/src/internal/bot/command/general/schedule/response.go new file mode 100644 index 00000000..76c46cef --- /dev/null +++ b/src/internal/bot/command/general/schedule/response.go @@ -0,0 +1,35 @@ +package schedule + +import "github.com/bwmarrin/discordgo" + +func RespondEdit(s *discordgo.Session, i *discordgo.InteractionCreate, data *discordgo.InteractionResponseData) error { + if data == nil { + data = &discordgo.InteractionResponseData{} + } + + edit := &discordgo.WebhookEdit{Flags: data.Flags} + if data.Content != "" { + content := data.Content + edit.Content = &content + } + if data.Components != nil { + components := data.Components + edit.Components = &components + } + if data.Embeds != nil { + embeds := data.Embeds + edit.Embeds = &embeds + } + if data.AllowedMentions != nil { + edit.AllowedMentions = data.AllowedMentions + } + if data.Files != nil { + edit.Files = data.Files + } + if data.Attachments != nil { + edit.Attachments = data.Attachments + } + + _, err := s.InteractionResponseEdit(i.Interaction, edit) + return err +} diff --git a/src/internal/bot/command/general/schedule/set.go b/src/internal/bot/command/general/schedule/set.go new file mode 100644 index 00000000..eb3d5b26 --- /dev/null +++ b/src/internal/bot/command/general/schedule/set.go @@ -0,0 +1,156 @@ +package schedule + +import ( + "unibot/internal" + + "github.com/bwmarrin/discordgo" +) + +const ( + ScheduleModalOnetimeButtonID = "schedule_open_onetime" + ScheduleModalRepeatButtonID = "schedule_open_repeat" +) + +func LoadSetCommandContext() *discordgo.ApplicationCommandOption { + return &discordgo.ApplicationCommandOption{ + Type: discordgo.ApplicationCommandOptionSubCommand, + Name: "set", + Description: "予約投稿を作成します", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionBoolean, + Name: "repeat", + Description: "繰り返し投稿にするかどうか", + Required: false, + }, + }, + } +} + +func Set(ctx *internal.BotContext, s *discordgo.Session, i *discordgo.InteractionCreate) { + options := i.ApplicationCommandData().Options[0].Options + isRepeat := false + hasRepeatOption := false + + for _, opt := range options { + if opt.Name == "repeat" { + isRepeat = opt.BoolValue() + hasRepeatOption = true + } + } + + showOnetime := true + showRepeat := false + if hasRepeatOption { + showOnetime = !isRepeat + showRepeat = isRepeat + } + + promptScheduleModal(s, i, showOnetime, showRepeat) +} + +func promptScheduleModal(s *discordgo.Session, i *discordgo.InteractionCreate, showOnetime, showRepeat bool) { + components := buildScheduleModalButtons(showOnetime, showRepeat) + if len(components) == 0 { + components = buildScheduleModalButtons(true, false) + } + + content := "単発の予約投稿を作成します。下のボタンからフォームを開いてください。" + if showOnetime && showRepeat { + content = "作成する予約投稿の種類を選んでください。" + } else if showRepeat { + content = "繰り返しの予約投稿を作成します。下のボタンからフォームを開いてください。" + } + + _ = RespondEdit(s, i, &discordgo.InteractionResponseData{ + Content: content, + Components: components, + Flags: discordgo.MessageFlagsEphemeral, + }) +} + +func buildScheduleModalButtons(showOnetime, showRepeat bool) []discordgo.MessageComponent { + buttons := make([]discordgo.MessageComponent, 0, 2) + if showOnetime { + buttons = append(buttons, discordgo.Button{ + CustomID: ScheduleModalOnetimeButtonID, + Label: "単発", + Style: discordgo.PrimaryButton, + }) + } + if showRepeat { + buttons = append(buttons, discordgo.Button{ + CustomID: ScheduleModalRepeatButtonID, + Label: "繰り返し", + Style: discordgo.SecondaryButton, + }) + } + if len(buttons) == 0 { + return nil + } + return []discordgo.MessageComponent{ + discordgo.ActionsRow{Components: buttons}, + } +} + +// ShowOnetimeModal opens the one-time schedule modal. +func ShowOnetimeModal(s *discordgo.Session, i *discordgo.InteractionCreate) { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseModal, + Data: &discordgo.InteractionResponseData{ + CustomID: "schedule_create_onetime", + Title: "予約投稿の作成", + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + discordgo.TextInput{ + CustomID: "time", + Label: "投稿時間 (YYYY-MM-DD HH:mm / JST)", + Style: discordgo.TextInputShort, + Placeholder: "例: 2026-12-31 23:59", + Required: true, + }, + }}, + discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + discordgo.TextInput{ + CustomID: "message", + Label: "投稿内容", + Style: discordgo.TextInputParagraph, + Placeholder: "投稿内容を入力してください", + Required: true, + }, + }}, + }, + }, + }) +} + +// ShowRepeatModal opens the repeating schedule modal. +func ShowRepeatModal(s *discordgo.Session, i *discordgo.InteractionCreate) { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseModal, + Data: &discordgo.InteractionResponseData{ + CustomID: "schedule_create_repeat", + Title: "予約投稿の作成", + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + discordgo.TextInput{ + CustomID: "time", + Label: "投稿時間 (JST)", + Style: discordgo.TextInputShort, + Placeholder: "例: every day at 12:00, every Monday at 09:00", + Required: true, + }, + }}, + discordgo.ActionsRow{Components: []discordgo.MessageComponent{ + discordgo.TextInput{ + CustomID: "message", + Label: "投稿内容", + Style: discordgo.TextInputParagraph, + Placeholder: "投稿内容を入力してください", + Required: true, + }, + }}, + }, + }, + }) +} diff --git a/src/internal/bot/command/general/unpin.go b/src/internal/bot/command/general/unpin.go new file mode 100644 index 00000000..c5494320 --- /dev/null +++ b/src/internal/bot/command/general/unpin.go @@ -0,0 +1,68 @@ +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 { + _ = s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + 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.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + 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..af2b432e 100644 --- a/src/internal/bot/command/registry.go +++ b/src/internal/bot/command/registry.go @@ -11,6 +11,10 @@ import ( var Handlers = map[string]func(*internal.BotContext, *discordgo.Session, *discordgo.InteractionCreate){ "ping": general.Ping, "about": general.About, + "pin": general.Pin, + "unpin": general.Unpin, + "Pinするメッセージを選択": general.PinSelect, + "schedule": general.Schedule, "maintenance": admin.Maintenance, "tts": general.Tts, "help": general.Help, diff --git a/src/internal/bot/handler/interaction.go b/src/internal/bot/handler/interaction.go index 97ea38fc..d73a2739 100644 --- a/src/internal/bot/handler/interaction.go +++ b/src/internal/bot/handler/interaction.go @@ -4,8 +4,9 @@ import ( "strings" "unibot/internal" "unibot/internal/bot/command" + "unibot/internal/bot/command/general" + "unibot/internal/bot/command/general/schedule" "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,11 @@ 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 + } + if schedule.HandleModalSubmit(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/bot/messageComponent/schedule.go b/src/internal/bot/messageComponent/schedule.go new file mode 100644 index 00000000..788f63e5 --- /dev/null +++ b/src/internal/bot/messageComponent/schedule.go @@ -0,0 +1,21 @@ +package messageComponent + +import ( + "unibot/internal" + schedulecmd "unibot/internal/bot/command/general/schedule" + + "github.com/bwmarrin/discordgo" +) + +func init() { + RegisterHandler(schedulecmd.ScheduleModalOnetimeButtonID, HandleScheduleOnetimeModal) + RegisterHandler(schedulecmd.ScheduleModalRepeatButtonID, HandleScheduleRepeatModal) +} + +func HandleScheduleOnetimeModal(ctx *internal.BotContext, s *discordgo.Session, i *discordgo.InteractionCreate) { + schedulecmd.ShowOnetimeModal(s, i) +} + +func HandleScheduleRepeatModal(ctx *internal.BotContext, s *discordgo.Session, i *discordgo.InteractionCreate) { + schedulecmd.ShowRepeatModal(s, i) +} 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) +} diff --git a/src/internal/repository/schedule_setting.go b/src/internal/repository/schedule_setting.go index e66906dc..75d25c37 100644 --- a/src/internal/repository/schedule_setting.go +++ b/src/internal/repository/schedule_setting.go @@ -57,6 +57,13 @@ func (r *ScheduleSettingRepository) GetByChannelID(channelID string) ([]*model.S return scheduleSettings, err } +// 指定時刻以前のスケジュール設定を取得する関数 +func (r *ScheduleSettingRepository) ListDue(now int64) ([]*model.ScheduleSetting, error) { + var scheduleSettings []*model.ScheduleSetting + err := r.db.Where("next_run_at <= ?", now).Order("next_run_at ASC").Find(&scheduleSettings).Error + return scheduleSettings, err +} + // 全てのスケジュール設定を取得する関数 func (r *ScheduleSettingRepository) List() ([]*model.ScheduleSetting, error) { var scheduleSettings []*model.ScheduleSetting diff --git a/src/internal/scheduler/manager.go b/src/internal/scheduler/manager.go new file mode 100644 index 00000000..4524a42a --- /dev/null +++ b/src/internal/scheduler/manager.go @@ -0,0 +1,115 @@ +package scheduler + +import ( + "log" + "time" + "unibot/internal" + "unibot/internal/model" + "unibot/internal/repository" + + "github.com/bwmarrin/discordgo" + "github.com/robfig/cron/v3" +) + +type Manager struct { + ctx *internal.BotContext + s *discordgo.Session + stop chan struct{} +} + +// 新しいスケジューラを作成する +func NewManager(ctx *internal.BotContext, s *discordgo.Session) *Manager { + return &Manager{ctx: ctx, s: s, stop: make(chan struct{})} +} + +// スケジューラを開始する +func (m *Manager) Start() { + go m.loop() +} + +// スケジューラを停止する +func (m *Manager) Stop() { + close(m.stop) +} + +func (m *Manager) loop() { + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + m.processDue() + case <-m.stop: + return + } + } +} + +func (m *Manager) processDue() { + now := time.Now().Unix() + repo := repository.NewScheduleSettingRepository(m.ctx.DB) + + settings, err := repo.ListDue(now) + if err != nil { + log.Printf("Failed to load schedules: %v", err) + return + } + + for _, setting := range settings { + m.execute(setting) + } +} + +func (m *Manager) execute(setting *model.ScheduleSetting) { + if setting == nil { + return + } + + _, err := m.s.ChannelMessageSend(setting.ChannelID, setting.Content) + if err != nil { + log.Printf("Failed to send scheduled message (id=%s): %v", setting.ID, err) + return + } + + repo := repository.NewScheduleSettingRepository(m.ctx.DB) + + // 単発なら削除 + if setting.Cron == "" { + err = repo.DeleteByID(setting.ID) + if err != nil { + log.Printf("Failed to delete schedule (id=%s): %v", setting.ID, err) + } + return + } + + // 繰り返しの場合は次回実行時刻を更新 + base := time.Unix(setting.NextRunAt, 0).In(JST()) + nextRunAt, err := NextRunAtFromCron(setting.Cron, base) + if err != nil { + log.Printf("Failed to parse cron (id=%s): %v", setting.ID, err) + return + } + + setting.NextRunAt = nextRunAt.Unix() + err = repo.Update(setting) + if err != nil { + log.Printf("Failed to update schedule (id=%s): %v", setting.ID, err) + } +} + +// Cron文字列から次回実行時間を取得する +func NextRunAtFromCron(cronText string, base time.Time) (time.Time, error) { + parser := cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) + schedule, err := parser.Parse(cronText) + if err != nil { + return time.Time{}, err + } + + return schedule.Next(base), nil +} + +// JSTロケーション +func JST() *time.Location { + return time.FixedZone("JST", 9*60*60) +}