diff --git a/.dockerignore b/.dockerignore index cde229b..10f229a 100644 --- a/.dockerignore +++ b/.dockerignore @@ -7,4 +7,3 @@ README.md update.sh compose.yml Dockerfile -script/ \ No newline at end of file diff --git a/.env.example b/.env.example index 093837c..7652c7a 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,24 @@ +# Database (URL 형식과 Non-URL 형식 중 하나를 선택) +## URL 형식 DATABASE_URL= + +## Non-URL 형식 +DATABASE_USERNAME= +DATABASE_PASSWORD= +DATABASE_HOSTNAME= # 해당 값은 도커로 실행할 때 필요하지 않음. +DATABASE_PORT= +DATABASE_AUTH_SOURCE= # 기본 값: admin + +## 필수 값 DATABASE_NAME= +## 데이터베이스 마이그레이션용 +PREVIOUS_DATABASE_URL= + +# 봇 BOT_TOKEN= BOT_PREFIX= BOT_OWNER_ID= -# If you need -PREVIOUS_DATABASE_URL= - +# 학습 (필수 아님) TRAIN_USER_ID= \ No newline at end of file diff --git a/.gitignore b/.gitignore index b8b14c5..a61dd0c 100644 --- a/.gitignore +++ b/.gitignore @@ -124,4 +124,7 @@ $RECYCLE.BIN/ # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) # Config files *.env -!.env.example \ No newline at end of file +!.env.example + +export/ +data/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..082b194 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "makefile.configureOnOpen": false +} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index bcb44f5..47da25d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,6 @@ -FROM golang:1.24.2 +FROM golang:1.24.3 + +ENV DATABASE_NAME=muffin_ai RUN mkdir /app WORKDIR /app diff --git a/README.md b/README.md index b83ff8e..0ef3522 100644 --- a/README.md +++ b/README.md @@ -12,8 +12,10 @@ ## 실행 +- 아래의 모든 명령어는 make를 사용합니다. + ```sh -go run main.go +make run ``` ### 빌드 @@ -21,7 +23,7 @@ go run main.go 1. 빌드 ```sh -go build -o ./build/goMuffin git.wh64.net/muffin/goMuffin # 윈도우면 ./build/goMuffin을 .\build\goMuffin.exe으로 변경 +make #또는 make build ``` 2. 실행 diff --git a/commands/dataLength.go b/commands/dataLength.go index e14ffaa..6ee92eb 100644 --- a/commands/dataLength.go +++ b/commands/dataLength.go @@ -89,9 +89,9 @@ func dataLengthRun(s *discordgo.Session, m any) { } dataLengthWg.Add(5) - go getLength(text, databases.Texts, bson.D{{}}) - go getLength(muffin, databases.Texts, bson.D{{Key: "persona", Value: "muffin"}}) - go getLength(nsfw, databases.Texts, bson.D{ + go getLength(text, databases.Database.Texts, bson.D{{}}) + go getLength(muffin, databases.Database.Texts, bson.D{{Key: "persona", Value: "muffin"}}) + go getLength(nsfw, databases.Database.Texts, bson.D{ { Key: "persona", Value: bson.M{ @@ -99,8 +99,8 @@ func dataLengthRun(s *discordgo.Session, m any) { }, }, }) - go getLength(learn, databases.Learns, bson.D{{}}) - go getLength(userLearn, databases.Learns, bson.D{{Key: "user_id", Value: userId}}) + go getLength(learn, databases.Database.Learns, bson.D{{}}) + go getLength(userLearn, databases.Database.Learns, bson.D{{Key: "user_id", Value: userId}}) go func() { dataLengthWg.Wait() diff --git a/commands/deleteLearnedData.go b/commands/deleteLearnedData.go index 105c928..3ce337d 100644 --- a/commands/deleteLearnedData.go +++ b/commands/deleteLearnedData.go @@ -31,7 +31,7 @@ var DeleteLearnedDataCommand *Command = &Command{ }, Category: Chatting, MessageRun: func(ctx *MsgContext) { - deleteLearnedDataRun(ctx.Command, ctx.Session, ctx.Msg, &ctx.Args) + deleteLearnedDataRun(ctx.Command, ctx.Session, ctx.Msg, ctx.Args) }, ChatInputRun: func(ctx *ChatInputContext) { deleteLearnedDataRun(ctx.Command, ctx.Session, ctx.Inter, nil) @@ -74,7 +74,7 @@ func deleteLearnedDataRun(c *Command, s *discordgo.Session, m any, args *[]strin userId = m.Member.User.ID } - cur, err := databases.Learns.Find(context.TODO(), bson.M{"user_id": userId, "command": command}) + cur, err := databases.Database.Learns.Find(context.TODO(), bson.M{"user_id": userId, "command": command}) if err != nil { embed := &discordgo.MessageEmbed{ Title: "❌ 오류", @@ -119,7 +119,7 @@ func deleteLearnedDataRun(c *Command, s *discordgo.Session, m any, args *[]strin options = append(options, discordgo.SelectMenuOption{ Label: fmt.Sprintf("%d번 지식", i+1), Description: data.Result, - Value: fmt.Sprintf("%s%s&No.%d", utils.DeleteLearnedData, data.Id.Hex(), i+1), + Value: utils.MakeDeleteLearnedData(data.Id.Hex(), i+1), }) description += fmt.Sprintf("%d. %s\n", i+1, data.Result) } @@ -135,7 +135,7 @@ func deleteLearnedDataRun(c *Command, s *discordgo.Session, m any, args *[]strin Components: []discordgo.MessageComponent{ discordgo.SelectMenu{ MenuType: discordgo.StringSelectMenu, - CustomID: utils.DeleteLearnedDataUserId + userId, + CustomID: utils.MakeDeleteLearnedDataUserId(userId), Options: options, Placeholder: "ㅈ지울 응답을 선택해주세요.", }, @@ -144,7 +144,7 @@ func deleteLearnedDataRun(c *Command, s *discordgo.Session, m any, args *[]strin discordgo.ActionsRow{ Components: []discordgo.MessageComponent{ discordgo.Button{ - CustomID: utils.DeleteLearnedDataCancel + userId, + CustomID: utils.MakeDeleteLearnedDataCancel(userId), Label: "취소하기", Style: discordgo.DangerButton, Disabled: false, diff --git a/commands/discommand.go b/commands/discommand.go index 3221306..53bafba 100644 --- a/commands/discommand.go +++ b/commands/discommand.go @@ -7,9 +7,12 @@ import ( "github.com/bwmarrin/discordgo" ) +type modalRun func(ctx *ModalContext) type messageRun func(ctx *MsgContext) type chatInputRun func(ctx *ChatInputContext) type componentRun func(ctx *ComponentContext) + +type modalParse func(ctx *ModalContext) bool type componentParse func(ctx *ComponentContext) bool type Category string @@ -32,12 +35,13 @@ type DiscommandStruct struct { Commands map[string]*Command Components []*Component Aliases map[string]string + Modals []*Modal } type MsgContext struct { Session *discordgo.Session Msg *discordgo.MessageCreate - Args []string + Args *[]string Command *Command } @@ -53,24 +57,38 @@ type ComponentContext struct { Component *Component } +type ModalContext struct { + Inter *utils.InteractionCreate + Modal *Modal +} + type Component struct { Parse componentParse Run componentRun } +type Modal struct { + Parse modalParse + Run modalRun +} + const ( Chatting Category = "채팅" General Category = "일반" ) -var commandMutex sync.Mutex -var componentMutex sync.Mutex +var ( + commandMutex sync.Mutex + componentMutex sync.Mutex + modalMutex sync.Mutex +) func new() *DiscommandStruct { discommand := DiscommandStruct{ Commands: map[string]*Command{}, Aliases: map[string]string{}, Components: []*Component{}, + Modals: []*Modal{}, } return &discommand } @@ -92,9 +110,15 @@ func (d *DiscommandStruct) LoadComponent(c *Component) { d.Components = append(d.Components, c) } +func (d *DiscommandStruct) LoadModal(m *Modal) { + defer modalMutex.Unlock() + modalMutex.Lock() + d.Modals = append(d.Modals, m) +} + func (d *DiscommandStruct) MessageRun(name string, s *discordgo.Session, m *discordgo.MessageCreate, args []string) { if command, ok := d.Commands[name]; ok { - command.MessageRun(&MsgContext{s, m, args, command}) + command.MessageRun(&MsgContext{s, m, &args, command}) } } @@ -109,18 +133,42 @@ func (d *DiscommandStruct) ChatInputRun(name string, s *discordgo.Session, i *di } func (d *DiscommandStruct) ComponentRun(s *discordgo.Session, i *discordgo.InteractionCreate) { - for _, c := range d.Components { - if (!c.Parse(&ComponentContext{s, &utils.InteractionCreate{ + data := &ComponentContext{ + Session: s, + Inter: &utils.InteractionCreate{ InteractionCreate: i, Session: s, - }, c})) { + }, + } + + for _, c := range d.Components { + data.Component = c + + if !c.Parse(data) { continue } - c.Run(&ComponentContext{s, &utils.InteractionCreate{ + c.Run(data) + break + } +} + +func (d *DiscommandStruct) ModalRun(s *discordgo.Session, i *discordgo.InteractionCreate) { + data := &ModalContext{ + Inter: &utils.InteractionCreate{ InteractionCreate: i, Session: s, - }, c}) + }, + } + + for _, m := range d.Modals { + data.Modal = m + + if !m.Parse(data) { + continue + } + + m.Run(data) break } } diff --git a/commands/help.go b/commands/help.go index 4d1d3df..7de4052 100644 --- a/commands/help.go +++ b/commands/help.go @@ -30,10 +30,10 @@ var HelpCommand *Command = &Command{ }, Category: General, MessageRun: func(ctx *MsgContext) { - helpRun(ctx.Command, ctx.Session, ctx.Msg, &ctx.Args) + helpRun(ctx.Session, ctx.Msg, ctx.Args) }, ChatInputRun: func(ctx *ChatInputContext) { - helpRun(ctx.Command, ctx.Session, ctx.Inter, nil) + helpRun(ctx.Session, ctx.Inter, nil) }, } @@ -47,7 +47,7 @@ func getCommandsByCategory(d *DiscommandStruct, category Category) []string { return commands } -func helpRun(c *Command, s *discordgo.Session, m any, args *[]string) { +func helpRun(s *discordgo.Session, m any, args *[]string) { var commandName string embed := &discordgo.MessageEmbed{ Color: utils.EmbedDefault, @@ -106,6 +106,13 @@ func helpRun(c *Command, s *discordgo.Session, m any, args *[]string) { }, } + if command.Name == LearnCommand.Name { + embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{ + Name: "대답에 쓸 수 있는 인자", + Value: learnArguments, + }) + } + if command.Aliases != nil { embed.Fields = append(embed.Fields, &discordgo.MessageEmbedField{ Name: "별칭", diff --git a/commands/learn.go b/commands/learn.go index 483c7b6..0515cd4 100644 --- a/commands/learn.go +++ b/commands/learn.go @@ -13,7 +13,7 @@ import ( "github.com/bwmarrin/discordgo" ) -var arguments = utils.InlineCode("{user.name}") + "\n" + +var learnArguments = utils.InlineCode("{user.name}") + "\n" + utils.InlineCode("{user.mention}") + "\n" + utils.InlineCode("{user.globalName}") + "\n" + utils.InlineCode("{user.id}") + "\n" + @@ -57,7 +57,7 @@ var LearnCommand *Command = &Command{ }, Category: Chatting, MessageRun: func(ctx *MsgContext) { - learnRun(ctx.Command, ctx.Session, ctx.Msg, &ctx.Args) + learnRun(ctx.Command, ctx.Session, ctx.Msg, ctx.Args) }, ChatInputRun: func(ctx *ChatInputContext) { learnRun(ctx.Command, ctx.Session, ctx.Inter, nil) @@ -91,7 +91,7 @@ func learnRun(c *Command, s *discordgo.Session, m any, args *[]string) { }, { Name: "사용 가능한 인자", - Value: arguments, + Value: learnArguments, Inline: true, }, { @@ -173,7 +173,24 @@ func learnRun(c *Command, s *discordgo.Session, m any, args *[]string) { } } - _, err := databases.Learns.InsertOne(context.TODO(), databases.InsertLearn{ + if len([]rune(command)) > 100 { + embed := &discordgo.MessageEmbed{ + Title: "❌ 오류", + Description: "단어는 100글자를 못 넘ㅇ어가요.", + Color: utils.EmbedFail, + } + + switch m := m.(type) { + case *discordgo.MessageCreate: + s.ChannelMessageSendEmbedReply(m.ChannelID, embed, m.Reference()) + case *utils.InteractionCreate: + m.EditReply(&discordgo.WebhookEdit{ + Embeds: &[]*discordgo.MessageEmbed{embed}, + }) + } + } + + _, err := databases.Database.Learns.InsertOne(context.TODO(), databases.InsertLearn{ Command: command, Result: result, UserId: userId, diff --git a/commands/learnedDataList.go b/commands/learnedDataList.go index 9c3554c..7647859 100644 --- a/commands/learnedDataList.go +++ b/commands/learnedDataList.go @@ -3,6 +3,7 @@ package commands import ( "context" "fmt" + "strconv" "strings" "git.wh64.net/muffin/goMuffin/configs" @@ -13,50 +14,191 @@ import ( "go.mongodb.org/mongo-driver/v2/mongo" ) +var ( + LIST_MIN_VALUE float64 = 10.0 + LIST_MAX_VALUE float64 = 100.0 +) + var LearnedDataListCommand *Command = &Command{ ApplicationCommand: &discordgo.ApplicationCommand{ Type: discordgo.ChatApplicationCommand, Name: "리스트", Description: "당신이 가ㄹ르쳐준 지식을 나열해요.", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "단어", + Description: "해당 단어가 포함된 결과만 찾아요.", + Required: false, + }, + { + Type: discordgo.ApplicationCommandOptionString, + Name: "대답", + Description: "해당 대답이 포함된 결과만 찾아요.", + Required: false, + }, + { + Type: discordgo.ApplicationCommandOptionInteger, + Name: "개수", + Description: "한 페이지당 보여줄 지식의 데이터 양을 정해요.", + MinValue: &LIST_MIN_VALUE, + MaxValue: LIST_MAX_VALUE, + Required: false, + }, + }, }, Aliases: []string{"list", "목록", "지식목록"}, DetailedDescription: &DetailedDescription{ Usage: fmt.Sprintf("%s리스트", configs.Config.Bot.Prefix), + Examples: []string{ + fmt.Sprintf("%s리스트 ㅁㄴㅇㄹ", configs.Config.Bot.Prefix), + fmt.Sprintf("%s리스트 단어:안녕", configs.Config.Bot.Prefix), + fmt.Sprintf("%s리스트 대답:머핀", configs.Config.Bot.Prefix), + }, }, Category: Chatting, MessageRun: func(ctx *MsgContext) { - learnedDataListRun(ctx.Session, ctx.Msg) + learnedDataListRun(ctx.Session, ctx.Msg, ctx.Args) }, ChatInputRun: func(ctx *ChatInputContext) { - learnedDataListRun(ctx.Session, ctx.Inter) + learnedDataListRun(ctx.Session, ctx.Inter, nil) }, } -func getDescriptions(data *[]databases.Learn) (descriptions []string) { +func getDescriptions(data *[]databases.Learn, length int) (descriptions []string) { + var builder strings.Builder + MAX_LENGTH := 100 + + if length == 0 { + length = 25 + } + + tempDesc := []string{} + for _, data := range *data { - descriptions = append(descriptions, fmt.Sprintf("- %s: %s", data.Command, data.Result)) + command := data.Command + result := data.Result + + if runeCommand := []rune(command); len(runeCommand) >= MAX_LENGTH { + command = string(runeCommand)[:MAX_LENGTH] + "..." + } + + if runeResult := []rune(result); len(runeResult) >= MAX_LENGTH { + result = string(runeResult[:MAX_LENGTH]) + "..." + } + + tempDesc = append(tempDesc, fmt.Sprintf("- %s: %s\n", command, result)) + } + + for i, s := range tempDesc { + builder.WriteString(s) + + if (i+1)%length == 0 { + descriptions = append(descriptions, builder.String()) + builder.Reset() + } + } + + if builder.Len() > 0 { + descriptions = append(descriptions, builder.String()) } return } -func learnedDataListRun(s *discordgo.Session, m any) { - var userId, globalName, avatarUrl string +func learnedDataListRun(s *discordgo.Session, m any, args *[]string) { + var globalName, avatarUrl string var data []databases.Learn + var filter bson.D + var length int switch m := m.(type) { case *discordgo.MessageCreate: - userId = m.Author.ID + filter = bson.D{{Key: "user_id", Value: m.Author.ID}} globalName = m.Author.GlobalName avatarUrl = m.Author.AvatarURL("512") + + query := strings.Join(*args, " ") + + if match := utils.RegexpLearnQueryCommand.FindStringSubmatch(query); match != nil { + filter = append(filter, bson.E{ + Key: "command", + Value: bson.M{ + "$regex": match[1], + }, + }) + } + + if match := utils.RegexpLearnQueryResult.FindStringSubmatch(query); match != nil { + filter = append(filter, bson.E{ + Key: "result", + Value: bson.M{ + "$regex": match[1], + }, + }) + } + + if match := utils.RegexpLearnQueryLength.FindStringSubmatch(query); match != nil { + var err error + length, err = strconv.Atoi(match[1]) + + if err != nil { + s.ChannelMessageSendEmbedReply(m.ChannelID, &discordgo.MessageEmbed{ + Title: "❌ 오류", + Description: "개수의 값은 숫자여야해요.", + Color: utils.EmbedFail, + }, m.Reference()) + return + } + + if float64(length) < LIST_MIN_VALUE { + s.ChannelMessageSendEmbedReply(m.ChannelID, &discordgo.MessageEmbed{ + Title: "❌ 오류", + Description: fmt.Sprintf("개수의 값은 %d보다 커야해요.", int(LIST_MIN_VALUE)), + Color: utils.EmbedFail, + }, m.Reference()) + return + } + + if float64(length) > LIST_MAX_VALUE { + s.ChannelMessageSendEmbedReply(m.ChannelID, &discordgo.MessageEmbed{ + Title: "❌ 오류", + Description: fmt.Sprintf("개수의 값은 %d보다 작아야해요.", int(LIST_MAX_VALUE)), + Color: utils.EmbedFail, + }, m.Reference()) + return + } + } case *utils.InteractionCreate: m.DeferReply(true) - userId = m.Member.User.ID + filter = bson.D{{Key: "user_id", Value: m.Member.User.ID}} globalName = m.Member.User.GlobalName avatarUrl = m.Member.User.AvatarURL("512") + + if opt, ok := m.Options["단어"]; ok { + filter = append(filter, bson.E{ + Key: "command", + Value: bson.M{ + "$regex": opt.StringValue(), + }, + }) + } + + if opt, ok := m.Options["대답"]; ok { + filter = append(filter, bson.E{ + Key: "result", + Value: bson.M{ + "$regex": opt.StringValue(), + }, + }) + } + + if opt, ok := m.Options["개수"]; ok { + length = int(opt.IntValue()) + } } - cur, err := databases.Learns.Find(context.TODO(), bson.D{{Key: "user_id", Value: userId}}) + cur, err := databases.Database.Learns.Find(context.TODO(), filter) if err != nil { if err == mongo.ErrNoDocuments { embed := &discordgo.MessageEmbed{ @@ -99,20 +241,12 @@ func learnedDataListRun(s *discordgo.Session, m any) { cur.All(context.TODO(), &data) embed := &discordgo.MessageEmbed{ - Title: fmt.Sprintf("%s님이 알려주신 지식", globalName), - Description: utils.CodeBlock("md", fmt.Sprintf("# 총 %d개에요.\n%s", len(data), strings.Join(getDescriptions(&data), "\n"))), - Color: utils.EmbedDefault, + Title: fmt.Sprintf("%s님이 알려주신 지식", globalName), + Color: utils.EmbedDefault, Thumbnail: &discordgo.MessageEmbedThumbnail{ URL: avatarUrl, }, } - switch m := m.(type) { - case *discordgo.MessageCreate: - s.ChannelMessageSendEmbedReply(m.ChannelID, embed, m.Reference()) - case *utils.InteractionCreate: - m.EditReply(&discordgo.WebhookEdit{ - Embeds: &[]*discordgo.MessageEmbed{embed}, - }) - } + utils.StartPaginationEmbed(s, m, embed, getDescriptions(&data, length), utils.CodeBlock("md", fmt.Sprintf("# 총 %d개에요.\n", len(data))+"%s")) } diff --git a/components/deleteLearnedData.go b/components/deleteLearnedData.go index 4737d11..2902e25 100644 --- a/components/deleteLearnedData.go +++ b/components/deleteLearnedData.go @@ -23,7 +23,7 @@ var DeleteLearnedDataComponent *commands.Component = &commands.Component{ return false } - userId = customId[len(utils.DeleteLearnedDataCancel):] + userId = utils.GetDeleteLearnedDataUserId(customId) if i.Member.User.ID == userId { i.Update(&discordgo.InteractionResponseData{ Embeds: []*discordgo.MessageEmbed{ @@ -41,7 +41,7 @@ var DeleteLearnedDataComponent *commands.Component = &commands.Component{ return false } - userId = customId[len(utils.DeleteLearnedDataUserId):] + userId = utils.GetDeleteLearnedDataUserId(customId) } if i.Member.User.ID != userId { @@ -66,16 +66,15 @@ var DeleteLearnedDataComponent *commands.Component = &commands.Component{ i.DeferUpdate() - id, _ := bson.ObjectIDFromHex(strings.ReplaceAll(utils.ItemIdRegexp.ReplaceAllString(i.MessageComponentData().Values[0][len(utils.DeleteLearnedData):], ""), "&", "")) - itemId := strings.ReplaceAll(utils.ItemIdRegexp.FindAllString(i.MessageComponentData().Values[0], 1)[0], "No.", "") + id, itemId := utils.GetDeleteLearnedDataId(i.MessageComponentData().Values[0]) - databases.Learns.DeleteOne(context.TODO(), bson.D{{Key: "_id", Value: id}}) + databases.Database.Learns.DeleteOne(context.TODO(), bson.D{{Key: "_id", Value: id}}) i.EditReply(&discordgo.WebhookEdit{ Embeds: &[]*discordgo.MessageEmbed{ { Title: "✅ 삭제 완료", - Description: fmt.Sprintf("%s번을 삭ㅈ제했어요.", itemId), + Description: fmt.Sprintf("%d번을 삭ㅈ제했어요.", itemId), Color: utils.EmbedSuccess, }, }, diff --git a/components/paginationEmbed.go b/components/paginationEmbed.go new file mode 100644 index 0000000..f2175aa --- /dev/null +++ b/components/paginationEmbed.go @@ -0,0 +1,49 @@ +package components + +import ( + "strings" + + "git.wh64.net/muffin/goMuffin/commands" + "git.wh64.net/muffin/goMuffin/utils" + "github.com/bwmarrin/discordgo" +) + +var PaginationEmbedComponent *commands.Component = &commands.Component{ + Parse: func(ctx *commands.ComponentContext) bool { + i := ctx.Inter + + if i.MessageComponentData().ComponentType == discordgo.ButtonComponent { + customId := i.MessageComponentData().CustomID + + if !strings.HasPrefix(customId, utils.PaginationEmbedPrev) && !strings.HasPrefix(customId, utils.PaginationEmbedNext) && !strings.HasPrefix(customId, utils.PaginationEmbedPages) { + return false + } + + id := utils.GetPaginationEmbedId(customId) + userId := utils.GetPaginationEmbedUserId(id) + if i.Member.User.ID != userId { + return false + } + + if utils.GetPaginationEmbed(id) == nil { + return false + } + } else { + return false + } + return true + }, + Run: func(ctx *commands.ComponentContext) { + customId := ctx.Inter.MessageComponentData().CustomID + id := utils.GetPaginationEmbedId(customId) + p := utils.GetPaginationEmbed(id) + + if strings.HasPrefix(customId, utils.PaginationEmbedPrev) { + p.Prev(ctx.Inter) + } else if strings.HasPrefix(customId, utils.PaginationEmbedNext) { + p.Next(ctx.Inter) + } else { + p.ShowModal(ctx.Inter) + } + }, +} diff --git a/compose.yml b/compose.yml index 45aaf66..074d58a 100644 --- a/compose.yml +++ b/compose.yml @@ -5,4 +5,20 @@ services: env_file: - "./.env" volumes: - - "/etc/localtime:/etc/localtime" \ No newline at end of file + - "/etc/localtime:/etc/localtime" + depends_on: + - database + environment: + - "DATABASE_HOSTNAME=database" + database: + container_name: "goMuffin_database" + image: "mongo:7.0.17" + ports: + - "${DATABASE_PORT}:27017" + volumes: + - "./data:/data/db" + - "/etc/localtime:/etc/localtime" + environment: + - "MONGO_INITDB_ROOT_USERNAME=${DATABASE_USERNAME}" + - "MONGO_INITDB_ROOT_PASSWORD=${DATABASE_PASSWORD}" + \ No newline at end of file diff --git a/configs/config.go b/configs/config.go index a10ecef..0e51efe 100644 --- a/configs/config.go +++ b/configs/config.go @@ -4,6 +4,7 @@ import ( "fmt" "log" "os" + "strconv" "github.com/joho/godotenv" ) @@ -18,26 +19,41 @@ type trainConfig struct { UserID string } -// MuffinConfig for Muffin bot -type MuffinConfig struct { - Bot botConfig - Train trainConfig - DatabaseURL string - DBName string +type databaseConfig struct { + Name string + URL string + HostName string + Username string + Password string + AuthSource string + Port int } -func loadConfig() *MuffinConfig { - godotenv.Load() - config := &MuffinConfig{Bot: botConfig{}, Train: trainConfig{}} - setConfig(config) +// MuffinConfig for Muffin bot +type MuffinConfig struct { + Bot botConfig + Train trainConfig + Database databaseConfig - return config + // Deprecated: Use Database.URL + DatabaseURL string + + // Deprecated: Use Database.Name + DatabaseName string +} + +var Config *MuffinConfig + +func init() { + godotenv.Load() + Config = &MuffinConfig{Bot: botConfig{}, Train: trainConfig{}, Database: databaseConfig{}} + setConfig(Config) } func getRequiredValue(key string) string { value := os.Getenv(key) if value == "" { - log.Fatalln(fmt.Sprintf("[goMuffin] .env 파일에서 필요한 %s값이 없어요.", key)) + log.Fatalln(fmt.Sprintf("[goMuffin] .env 파일에서 필요한 '%s'값이 없어요.", key)) } return value } @@ -53,8 +69,29 @@ func setConfig(config *MuffinConfig) { config.Train.UserID = getValue("TRAIN_USER_ID") - config.DatabaseURL = getRequiredValue("DATABASE_URL") - config.DBName = getRequiredValue("DATABASE_NAME") -} + config.Database.URL = getValue("DATABASE_URL") + config.Database.HostName = getValue("DATABASE_HOSTNAME") + config.Database.Password = getValue("DATABASE_PASSWORD") + config.Database.Username = getValue("DATABASE_USERNAME") + config.Database.AuthSource = getValue("DATABASE_AUTH_SOURCE") + config.Database.Name = getRequiredValue("DATABASE_NAME") + port, err := strconv.Atoi(getValue("DATABASE_PORT")) + if err != nil { + log.Println("[goMuffin] 'DATABASE_PORT'값을 int로 파싱할 수 없어요.") + log.Fatalln(err) + } -var Config *MuffinConfig = loadConfig() + config.Database.Port = port + + if config.Database.AuthSource == "" { + config.Database.AuthSource = "admin" + } + + if config.Database.URL == "" { + config.Database.URL = fmt.Sprintf("mongodb://%s:%s@%s:%d/?authSource=%s", config.Database.Username, config.Database.Password, config.Database.HostName, config.Database.Port, config.Database.AuthSource) + } + + // Deprecated된 Value + config.DatabaseURL = config.Database.URL + config.DatabaseName = config.Database.Name +} diff --git a/configs/version.go b/configs/version.go index 314ff12..8a18398 100644 --- a/configs/version.go +++ b/configs/version.go @@ -7,9 +7,9 @@ import ( "git.wh64.net/muffin/goMuffin/utils" ) -const MUFFIN_VERSION = "5.0.2-gopher_release.250511a" +const MUFFIN_VERSION = "5.1.0-gopher_release.250524a" -var updatedString string = utils.Decimals.FindAllStringSubmatch(MUFFIN_VERSION, -1)[3][0] +var updatedString string = utils.RegexpDecimals.FindAllStringSubmatch(MUFFIN_VERSION, -1)[3][0] var UpdatedAt *time.Time = func() *time.Time { year, _ := strconv.Atoi("20" + updatedString[0:2]) diff --git a/databases/Learn.go b/databases/Learn.go index f0d25aa..cf0c265 100644 --- a/databases/Learn.go +++ b/databases/Learn.go @@ -3,24 +3,20 @@ package databases import ( "time" - "git.wh64.net/muffin/goMuffin/configs" "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" ) type InsertLearn struct { - Command string - Result string + Command string `bson:"command"` + Result string `bson:"result"` UserId string `bson:"user_id"` CreatedAt time.Time `bson:"created_at"` } type Learn struct { - Id bson.ObjectID `bson:"_id"` - Command string - Result string - UserId string `bson:"user_id"` - CreatedAt time.Time `bson:"created_at"` + Id bson.ObjectID `bson:"_id" json:"id"` + Command string `bson:"command" json:"command"` + Result string `bson:"result" json:"result"` + UserId string `bson:"user_id" json:"user_id"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` } - -var Learns *mongo.Collection = Client.Database(configs.Config.DBName).Collection("learn") diff --git a/databases/Text.go b/databases/Text.go index 06dc147..7bacbbe 100644 --- a/databases/Text.go +++ b/databases/Text.go @@ -3,22 +3,18 @@ package databases import ( "time" - "git.wh64.net/muffin/goMuffin/configs" "go.mongodb.org/mongo-driver/v2/bson" - "go.mongodb.org/mongo-driver/v2/mongo" ) type InsertText struct { - Text string - Persona string + Text string `bson:"text" json:"text"` + Persona string `bson:"persona" json:"persona"` CreatedAt time.Time `bson:"created_at"` } type Text struct { - Id bson.ObjectID `bson:"_id"` - Text string - Persona string - CreatedAt time.Time `bson:"created_at"` + Id bson.ObjectID `bson:"_id" json:"id"` + Text string `bson:"text" json:"text"` + Persona string `bson:"persona" json:"persona"` + CreatedAt time.Time `bson:"created_at" json:"created_at"` } - -var Texts *mongo.Collection = Client.Database(configs.Config.DBName).Collection("text") diff --git a/databases/database.go b/databases/database.go index 81115f2..26a6607 100644 --- a/databases/database.go +++ b/databases/database.go @@ -8,13 +8,31 @@ import ( "go.mongodb.org/mongo-driver/v2/mongo/options" ) -func connect() *mongo.Client { - client, err := mongo.Connect(options.Client().ApplyURI(configs.Config.DatabaseURL)) +type MuffinDatabase struct { + Client *mongo.Client + Learns *mongo.Collection + Texts *mongo.Collection +} + +var Database *MuffinDatabase + +func init() { + var err error + + Database, err = Connect() if err != nil { log.Fatalln(err) } - - return client } -var Client *mongo.Client = connect() +func Connect() (*MuffinDatabase, error) { + client, err := mongo.Connect(options.Client().ApplyURI(configs.Config.DatabaseURL)) + if err != nil { + return nil, err + } + return &MuffinDatabase{ + Client: client, + Learns: client.Database(configs.Config.DatabaseName).Collection("learn"), + Texts: client.Database(configs.Config.DatabaseName).Collection("text"), + }, nil +} diff --git a/go.mod b/go.mod index ba7f7b8..814397b 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.24.1 require ( github.com/LoperLee/golang-hangul-toolkit v1.1.0 github.com/bwmarrin/discordgo v0.28.1 + github.com/devproje/commando v0.1.0-alpha.1 github.com/go-sql-driver/mysql v1.9.2 github.com/joho/godotenv v1.5.1 go.mongodb.org/mongo-driver/v2 v2.1.0 diff --git a/go.sum b/go.sum index ac1c0fd..c976b9b 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,8 @@ github.com/bwmarrin/discordgo v0.28.1 h1:gXsuo2GBO7NbR6uqmrrBDplPUx2T3nzu775q/Rd github.com/bwmarrin/discordgo v0.28.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/devproje/commando v0.1.0-alpha.1 h1:JU6CKIdt1otjUKh+asCJC0yTzwVj+4Yh8KoTdzaKAkU= +github.com/devproje/commando v0.1.0-alpha.1/go.mod h1:OhrPX3mZUGSyEX/E7d1o0vaQIYkjG/N5rk6Nqwgyc7k= github.com/go-sql-driver/mysql v1.9.2 h1:4cNKDYQ1I84SXslGddlsrMhc8k4LeDVj6Ad6WRjiHuU= github.com/go-sql-driver/mysql v1.9.2/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= diff --git a/handler/interactionCreate.go b/handler/interactionCreate.go index 2152bd8..4dfbbbc 100644 --- a/handler/interactionCreate.go +++ b/handler/interactionCreate.go @@ -11,5 +11,7 @@ func InteractionCreate(s *discordgo.Session, i *discordgo.InteractionCreate) { return } else if i.Type == discordgo.InteractionMessageComponent { commands.Discommand.ComponentRun(s, i) + } else if i.Type == discordgo.InteractionModalSubmit { + commands.Discommand.ModalRun(s, i) } } diff --git a/handler/messageCreate.go b/handler/messageCreate.go index d9d98bb..99fda87 100644 --- a/handler/messageCreate.go +++ b/handler/messageCreate.go @@ -18,7 +18,7 @@ import ( ) func argParser(content string) (args []string) { - for _, arg := range utils.FlexibleStringParser.FindAllStringSubmatch(content, -1) { + for _, arg := range utils.RegexpFlexibleString.FindAllStringSubmatch(content, -1) { if arg[1] != "" { args = append(args, arg[1]) } else { @@ -60,7 +60,7 @@ func MessageCreate(s *discordgo.Session, m *discordgo.MessageCreate) { command := commands.Discommand.Aliases[args[0]] if m.Author.ID == config.Train.UserID { - if _, err := databases.Texts.InsertOne(context.TODO(), databases.InsertText{ + if _, err := databases.Database.Texts.InsertOne(context.TODO(), databases.InsertText{ Text: content, Persona: "muffin", CreatedAt: time.Now(), @@ -77,13 +77,13 @@ func MessageCreate(s *discordgo.Session, m *discordgo.MessageCreate) { var filter bson.D ch := make(chan int) - x := rand.Intn(5) + x := rand.Intn(10) channel, _ := s.Channel(m.ChannelID) if channel.NSFW { filter = bson.D{{}} - if _, err := databases.Texts.InsertOne(context.TODO(), databases.InsertText{ + if _, err := databases.Database.Texts.InsertOne(context.TODO(), databases.InsertText{ Text: content, Persona: fmt.Sprintf("user:%s", m.Author.Username), CreatedAt: time.Now(), @@ -96,7 +96,7 @@ func MessageCreate(s *discordgo.Session, m *discordgo.MessageCreate) { } go func() { - cur, err := databases.Texts.Find(context.TODO(), filter) + cur, err := databases.Database.Texts.Find(context.TODO(), filter) if err != nil { log.Fatalln(err) } @@ -107,7 +107,7 @@ func MessageCreate(s *discordgo.Session, m *discordgo.MessageCreate) { ch <- 1 }() go func() { - cur, err := databases.Learns.Find(context.TODO(), bson.D{{Key: "command", Value: content}}) + cur, err := databases.Database.Learns.Find(context.TODO(), bson.D{{Key: "command", Value: content}}) if err != nil { if err == mongo.ErrNilDocument { learnData = []databases.Learn{} @@ -131,7 +131,6 @@ func MessageCreate(s *discordgo.Session, m *discordgo.MessageCreate) { user, _ := s.User(data.UserId) result := resultParser(data.Result, s, m) - // s.ChannelMessageSendReply(m.ChannelID, fmt.Sprintf("%s\n%s", result, utils.InlineCode(fmt.Sprintf("%s님이 알려주셨어요.", user.Username))), m.Reference()) s.ChannelMessageSendComplex(m.ChannelID, &discordgo.MessageSend{ Reference: m.Reference(), Content: fmt.Sprintf("%s\n%s", result, utils.InlineCode(fmt.Sprintf("%s님이 알려주셨어요.", user.Username))), @@ -144,7 +143,6 @@ func MessageCreate(s *discordgo.Session, m *discordgo.MessageCreate) { return } - // s.ChannelMessageSendReply(m.ChannelID, data[rand.Intn(len(data))].Text, m.Reference()) s.ChannelMessageSendComplex(m.ChannelID, &discordgo.MessageSend{ Reference: m.Reference(), Content: data[rand.Intn(len(data))].Text, diff --git a/main.go b/main.go index 20a8878..5422466 100644 --- a/main.go +++ b/main.go @@ -6,7 +6,6 @@ import ( "log" "os" "os/signal" - "strings" "syscall" "time" @@ -15,30 +14,59 @@ import ( "git.wh64.net/muffin/goMuffin/configs" "git.wh64.net/muffin/goMuffin/databases" "git.wh64.net/muffin/goMuffin/handler" + "git.wh64.net/muffin/goMuffin/modals" "git.wh64.net/muffin/goMuffin/scripts" "github.com/bwmarrin/discordgo" + "github.com/devproje/commando" + "github.com/devproje/commando/types" ) func main() { + command := commando.NewCommando(os.Args[1:]) config := configs.Config if len(os.Args) > 1 { - switch strings.ToLower(os.Args[1]) { - case "dbmigrate": - scripts.DBMigrate() - case "deleteallcommands": - scripts.DeleteAllCommands() - default: - log.Fatalln(fmt.Errorf("[goMuffin] 명령어 인자에는 dbmigrate나 deleteallcommands만 올 수 있어요")) + command.Root("db-migrate", "봇의 데이터를 MariaDB에서 MongoDB로 옮깁니다.", scripts.DBMigrate) + command.Root("delete-all-commands", "봇의 모든 슬래시 커맨드를 삭제합니다.", scripts.DeleteAllCommands, + types.OptionData{ + Name: "id", + Desc: "봇의 디스코드 아이디", + Type: types.STRING, + }, + types.OptionData{ + Name: "isYes", + Short: []string{"y"}, + Type: types.BOOLEAN, + }, + ) + + command.Root("export", "머핀봇의 데이터를 추출합니다.", scripts.ExportData, + types.OptionData{ + Name: "type", + Desc: "파일형식을 지정합니다. (json, txt(txt는 머핀 데이터만 적용))", + Type: types.STRING, + }, + types.OptionData{ + Name: "export-path", + Desc: "데이터를 저장할 위치를 지정합니다.", + Type: types.STRING, + }, + types.OptionData{ + Name: "refined", + Desc: "머핀 데이터를 있는 그대로 추출할 지, 가려내서 추출할 지를 지정합니다.", + Type: types.BOOLEAN, + }, + ) + + err := command.Execute() + if err != nil { + _, _ = fmt.Fprintln(os.Stderr, err) + os.Exit(1) } return } - dg, err := discordgo.New("Bot " + config.Bot.Token) - if err != nil { - log.Println("[goMuffin] 봇의 세션을 만들수가 없어요.") - log.Fatalln(err) - } + dg, _ := discordgo.New("Bot " + config.Bot.Token) go commands.Discommand.LoadCommand(commands.HelpCommand) go commands.Discommand.LoadCommand(commands.DataLengthCommand) @@ -48,13 +76,22 @@ func main() { go commands.Discommand.LoadCommand(commands.DeleteLearnedDataCommand) go commands.Discommand.LoadComponent(components.DeleteLearnedDataComponent) + go commands.Discommand.LoadComponent(components.PaginationEmbedComponent) + + go commands.Discommand.LoadModal(modals.PaginationEmbedModal) go dg.AddHandler(handler.MessageCreate) go dg.AddHandler(handler.InteractionCreate) - dg.Open() + err := dg.Open() + if err != nil { + log.Println("[goMuffin] 봇을 시작할 수 없어요.") + log.Fatalln(err) + } + defer dg.Close() + // 봇의 상태메세지 변경 go func() { for { dg.UpdateCustomStatus("ㅅ살려주세요..!") @@ -63,7 +100,7 @@ func main() { }() for _, cmd := range commands.Discommand.Commands { - if cmd.Name == "도움말" { + if cmd.Name == commands.HelpCommand.Name { // 극한의 성능 똥망 코드 탄생! // 무려 똑같은 걸 반복해서 돌리는! for _, a := range commands.Discommand.Commands { @@ -77,7 +114,7 @@ func main() { go dg.ApplicationCommandCreate(dg.State.User.ID, "", cmd.ApplicationCommand) } - defer databases.Client.Disconnect(context.TODO()) + defer databases.Database.Client.Disconnect(context.TODO()) log.Println("[goMuffin] 봇이 실행되고 있어요. 버전:", configs.MUFFIN_VERSION) sc := make(chan os.Signal, 1) diff --git a/modals/paginationEmbed.go b/modals/paginationEmbed.go new file mode 100644 index 0000000..3f30342 --- /dev/null +++ b/modals/paginationEmbed.go @@ -0,0 +1,66 @@ +package modals + +import ( + "strconv" + "strings" + + "git.wh64.net/muffin/goMuffin/commands" + "git.wh64.net/muffin/goMuffin/utils" + "github.com/bwmarrin/discordgo" +) + +var PaginationEmbedModal *commands.Modal = &commands.Modal{ + Parse: func(ctx *commands.ModalContext) bool { + i := ctx.Inter + data := i.ModalSubmitData() + customId := data.CustomID + + if data.Components[0].Type() != discordgo.ActionsRowComponent { + return false + } + + if !strings.HasPrefix(customId, utils.PaginationEmbedModal) { + return false + } + + id := utils.GetPaginationEmbedId(customId) + userId := utils.GetPaginationEmbedUserId(id) + + if i.Member.User.ID != userId { + return false + } + + if utils.GetPaginationEmbed(id) == nil { + return false + } + + cmp := data.Components[0].(*discordgo.ActionsRow).Components[0].(*discordgo.TextInput) + + if _, err := strconv.Atoi(cmp.Value); err != nil { + i.Reply(&discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "❌ 오류", + Description: "해당 값은 숫자여야해요.", + Color: utils.EmbedFail, + }, + }, + Flags: discordgo.MessageFlagsEphemeral, + }) + return false + } + + return true + }, + Run: func(ctx *commands.ModalContext) { + data := ctx.Inter.ModalSubmitData() + customId := data.CustomID + id := utils.GetPaginationEmbedId(customId) + p := utils.GetPaginationEmbed(id) + cmp := data.Components[0].(*discordgo.ActionsRow).Components[0].(*discordgo.TextInput) + + page, _ := strconv.Atoi(cmp.Value) + + p.Set(ctx.Inter, page) + }, +} diff --git a/scripts/dbMigrate.go b/scripts/dbMigrate.go index 22fde88..fbd4a60 100644 --- a/scripts/dbMigrate.go +++ b/scripts/dbMigrate.go @@ -10,6 +10,7 @@ import ( "git.wh64.net/muffin/goMuffin/configs" + "github.com/devproje/commando" _ "github.com/go-sql-driver/mysql" "go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/mongo" @@ -19,15 +20,17 @@ import ( var wg sync.WaitGroup // 이 스크립트는 MariaDB -> MongoDB로의 전환을 위해 만들었음. -func DBMigrate() { +func DBMigrate(n *commando.Node) error { mariaURL := os.Getenv("PREVIOUS_DATABASE_URL") mongoURL := configs.Config.DatabaseURL - dbName := configs.Config.DBName + dbName := configs.Config.DatabaseName dbConnectionQuery := "?parseTime=true" wg.Add(3) + fmt.Println("[경고] 해당 명령어는 다음 버전에서 사라져요.") + // statement -> text go func() { defer wg.Done() @@ -189,4 +192,5 @@ func DBMigrate() { // 모든 고루틴이 끝날 떄 까지 대기 wg.Wait() fmt.Println("데이터 마이그레이션이 끝났어요.") + return nil } diff --git a/scripts/deleteAllCommands.go b/scripts/deleteAllCommands.go index 6e325df..af496c9 100644 --- a/scripts/deleteAllCommands.go +++ b/scripts/deleteAllCommands.go @@ -1,7 +1,6 @@ package scripts import ( - "flag" "fmt" "io" "net/http" @@ -10,40 +9,45 @@ import ( "git.wh64.net/muffin/goMuffin/configs" "github.com/bwmarrin/discordgo" + "github.com/devproje/commando" + "github.com/devproje/commando/option" ) -func DeleteAllCommands() { +func DeleteAllCommands(n *commando.Node) error { var answer string - id := flag.String("id", "", "디스코드 봇의 토큰") - flag.Parse() - - fmt.Printf("정말로 모든 명령어를 삭제하시겠어요? [y/N]: ") - fmt.Scanf("%s", &answer) - if strings.ToLower(answer) != "y" && strings.ToLower(answer) != "yes" { - os.Exit(1) + id, err := option.ParseString(*n.MustGetOpt("id"), n) + if err != nil { + return err } - if *id == "" { - panic(fmt.Errorf("--id 플래그의 값이 필요해요")) + yes, _ := option.ParseBool(*n.MustGetOpt("isYes"), n) + if !yes { + fmt.Printf("정말로 모든 명령어를 삭제하시겠어요? [y/N]: ") + fmt.Scanf("%s", &answer) + if strings.ToLower(answer) != "y" && strings.ToLower(answer) != "yes" { + fmt.Println("모든 명령어 삭제를 취소했어요.") + os.Exit(1) + } } c := http.Client{} - req, err := http.NewRequest("PUT", discordgo.EndpointApplicationGlobalCommands(*id), nil) + req, err := http.NewRequest("PUT", discordgo.EndpointApplicationGlobalCommands(id), nil) if err != nil { - panic(err) + return err } req.Header.Add("Authorization", "Bot "+configs.Config.Bot.Token) resp, err := c.Do(req) if err != nil { - panic(err) + return err } bytes, err := io.ReadAll(resp.Body) if err != nil { - panic(err) + return err } fmt.Println(string(bytes)) + return nil } diff --git a/scripts/export.go b/scripts/export.go new file mode 100644 index 0000000..3fb2b33 --- /dev/null +++ b/scripts/export.go @@ -0,0 +1,321 @@ +package scripts + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + "sync" + "time" + + "git.wh64.net/muffin/goMuffin/databases" + "git.wh64.net/muffin/goMuffin/utils" + "github.com/devproje/commando" + "github.com/devproje/commando/option" + "go.mongodb.org/mongo-driver/v2/bson" +) + +var date time.Time = time.Now() + +type textJSONLData struct { + Text string `json:"text"` + Persona string `json:"persona,omitempty"` +} + +type learnJSONLData struct { + Command string `json:"command"` + Result string `json:"result"` +} + +func getDate() string { + year := strconv.Itoa(date.Year()) + month := strconv.Itoa(int(date.Month())) + day := strconv.Itoa(date.Day()) + hour := strconv.Itoa(date.Hour()) + minute := strconv.Itoa(date.Minute()) + sec := strconv.Itoa(date.Second()) + + if len(month) < 2 { + month = "0" + month + } + + if len(day) < 2 { + day = "0" + day + } + + if len(hour) < 2 { + hour = "0" + hour + } + + if len(minute) < 2 { + minute = "0" + minute + } + + if len(sec) < 2 { + sec = "0" + sec + } + return year + month + day + hour + minute + sec +} + +func checkDir(path string) error { + _, err := os.ReadDir(path) + if err != nil { + err = os.MkdirAll(path, os.ModePerm) + if err != nil { + return err + } + } + return nil +} + +func saveFileToJSON(path, name string, data any) error { + bytes, err := json.MarshalIndent(data, "", " ") + if err != nil { + return err + } + + f, err := os.Create(fmt.Sprintf("%s/%s.json", path, name)) + if err != nil { + return err + } + + defer f.Close() + + _, err = f.Write(bytes) + if err != nil { + return err + } + return nil +} + +func saveFileToJSONL(path, name string, data any) error { + var content string + + switch data := data.(type) { + case []textJSONLData: + for _, data := range data { + bytes, err := json.Marshal(data) + if err != nil { + return err + } + content += string(bytes) + "\n" + } + case []learnJSONLData: + for _, data := range data { + bytes, err := json.Marshal(data) + if err != nil { + return err + } + content += string(bytes) + "\n" + } + } + + f, err := os.Create(fmt.Sprintf("%s/%s.jsonl", path, name)) + if err != nil { + return err + } + + defer f.Close() + + _, err = f.WriteString(content) + if err != nil { + return err + } + return nil +} + +func ExportData(n *commando.Node) error { + defer databases.Database.Client.Disconnect(context.TODO()) + + var wg sync.WaitGroup + ch := make(chan error, 3) + + fileType, err := option.ParseString(*n.MustGetOpt("type"), n) + if err != nil { + return err + } + + if fileType != "json" && fileType != "jsonl" { + return fmt.Errorf("파일 형식은 txt또는 json또는 jsonl이여야 해요") + } + + refined, err := option.ParseBool(*n.MustGetOpt("refined"), n) + if err != nil { + return err + } + + path, err := option.ParseString(*n.MustGetOpt("export-path"), n) + if err != nil { + return err + } + + path += "/" + getDate() + + err = checkDir(path) + if err != nil { + return err + } + + wg.Add(3) + + if fileType == "jsonl" { + fmt.Println("NOTE: 파일 형식이 'jsonl'인 경우 일부데이터만 추출 됩니다.") + } + + // 머핀 데이터 추출 + go func() { + defer wg.Done() + + var data []databases.Text + + cur, err := databases.Database.Texts.Find(context.TODO(), bson.D{{Key: "persona", Value: "muffin"}}) + if err != nil { + ch <- err + return + } + + defer cur.Close(context.TODO()) + + err = cur.All(context.TODO(), &data) + if err != nil { + ch <- err + return + } + + if refined { + for i, text := range data { + if utils.RegexpEmoji.Match([]byte(text.Text)) { + data = append(data[:i], data[i+1:]...) + return + } + + text.Text = strings.TrimPrefix(text.Text, "머핀아 ") + } + } + + if fileType == "json" { + err = saveFileToJSON(path, "muffin", data) + if err != nil { + ch <- err + return + } + } else if fileType == "jsonl" { + var newData []textJSONLData + + for _, data := range data { + newData = append(newData, textJSONLData{data.Text, ""}) + } + + err = saveFileToJSONL(path, "muffin", newData) + if err != nil { + ch <- err + return + } + } + + fmt.Println("머핀 데이터 추출 완료") + }() + + // nsfw 데이터 추출 + go func() { + defer wg.Done() + + var data []databases.Text + + cur, err := databases.Database.Texts.Find(context.TODO(), bson.D{ + { + Key: "persona", + Value: bson.M{ + "$regex": "^user", + }, + }, + }) + if err != nil { + ch <- err + return + } + + defer cur.Close(context.TODO()) + + err = cur.All(context.TODO(), &data) + if err != nil { + ch <- err + return + } + + if fileType == "json" { + err = saveFileToJSON(path, "nsfw", data) + if err != nil { + ch <- err + return + } + } else if fileType == "jsonl" { + var newData []textJSONLData + + for _, data := range data { + newData = append(newData, textJSONLData{data.Text, data.Persona}) + } + + err = saveFileToJSONL(path, "nsfw", newData) + if err != nil { + ch <- err + return + } + } + + fmt.Println("nsfw 데이터 추출 완료") + }() + + // 지식 데이터 추출 + go func() { + defer wg.Done() + + var data []databases.Learn + + cur, err := databases.Database.Learns.Find(context.TODO(), bson.D{{}}) + if err != nil { + ch <- err + return + } + + defer cur.Close(context.TODO()) + + err = cur.All(context.TODO(), &data) + if err != nil { + ch <- err + return + } + + if fileType == "json" { + err = saveFileToJSON(path, "learn", data) + if err != nil { + ch <- err + return + } + } else if fileType == "jsonl" { + var newData []learnJSONLData + + for _, data := range data { + newData = append(newData, learnJSONLData{data.Command, data.Result}) + } + + err = saveFileToJSONL(path, "learn", newData) + if err != nil { + ch <- err + return + } + } + + fmt.Println("지식 데이터 추출 완료") + }() + + wg.Wait() + close(ch) + + for err = range ch { + fmt.Println(err) + } + return nil +} diff --git a/utils/customIds.go b/utils/customIds.go index 6544d26..a37a55b 100644 --- a/utils/customIds.go +++ b/utils/customIds.go @@ -1,7 +1,89 @@ package utils +import ( + "fmt" + "strconv" + "strings" + + "go.mongodb.org/mongo-driver/v2/bson" +) + const ( - DeleteLearnedData = "#muffin/deleteLearnedData$" + DeleteLearnedData = "#muffin/deleteLearnedData@" DeleteLearnedDataUserId = "#muffin/deleteLearnedData@" DeleteLearnedDataCancel = "#muffin/deleteLearnedData/cancel@" + + PaginationEmbedPrev = "#muffin-pages/prev$" + PaginationEmbedPages = "#muffin-pages/pages$" + PaginationEmbedNext = "#muffin-pages/next$" + PaginationEmbedModal = "#muffin-pages/modal$" + PaginationEmbedSetPage = "#muffin-pages/modal/set$" ) + +func MakeDeleteLearnedData(id string, number int) string { + return fmt.Sprintf("%s%s&No.%d", DeleteLearnedData, id, number) +} + +func MakeDeleteLearnedDataUserId(userId string) string { + return fmt.Sprintf("%s%s", DeleteLearnedDataUserId, userId) +} + +func MakeDeleteLearnedDataCancel(id string) string { + return fmt.Sprintf("%s%s", DeleteLearnedDataCancel, id) +} + +func GetDeleteLearnedDataId(customId string) (id bson.ObjectID, itemId int) { + id, _ = bson.ObjectIDFromHex(strings.ReplaceAll(RegexpItemId.ReplaceAllString(customId[len(DeleteLearnedData):], ""), "&", "")) + stringItemId := strings.ReplaceAll(RegexpItemId.FindAllString(customId, 1)[0], "No.", "") + itemId, _ = strconv.Atoi(stringItemId) + return +} + +func GetDeleteLearnedDataUserId(customId string) string { + if strings.HasPrefix(customId, DeleteLearnedDataCancel) { + return customId[len(DeleteLearnedDataCancel):] + } else { + return customId[len(DeleteLearnedDataUserId):] + } +} + +func MakePaginationEmbedPrev(id string) string { + return fmt.Sprintf("%s%s", PaginationEmbedPrev, id) +} + +func MakePaginationEmbedPages(id string) string { + return fmt.Sprintf("%s%s", PaginationEmbedPages, id) +} + +func MakePaginationEmbedNext(id string) string { + return fmt.Sprintf("%s%s", PaginationEmbedNext, id) +} + +func MakePaginationEmbedModal(id string) string { + return fmt.Sprintf("%s%s", PaginationEmbedModal, id) +} + +func MakePaginationEmbedSetPage(id string) string { + return fmt.Sprintf("%s%s", PaginationEmbedSetPage, id) +} + +func GetPaginationEmbedId(customId string) string { + switch { + case strings.HasPrefix(customId, PaginationEmbedPrev): + return customId[len(PaginationEmbedPrev):] + case strings.HasPrefix(customId, PaginationEmbedPages): + return customId[len(PaginationEmbedPages):] + case strings.HasPrefix(customId, PaginationEmbedNext): + return customId[len(PaginationEmbedNext):] + case strings.HasPrefix(customId, PaginationEmbedModal): + return customId[len(PaginationEmbedModal):] + case strings.HasPrefix(customId, PaginationEmbedSetPage): + return customId[len(PaginationEmbedSetPage):] + default: + return customId + } +} + +func GetPaginationEmbedUserId(id string) string { + return RegexpPaginationEmbedId.FindAllStringSubmatch(id, 1)[0][1] +} diff --git a/utils/interactions.go b/utils/interactions.go index a2245be..199779e 100644 --- a/utils/interactions.go +++ b/utils/interactions.go @@ -1,6 +1,20 @@ package utils -import "github.com/bwmarrin/discordgo" +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/bwmarrin/discordgo" +) + +type ModalData struct { + CustomId string `json:"custom_id"` + Title string `json:"title"` + Components []discordgo.MessageComponent `json:"components"` +} // InteractionCreate custom data of discordgo.InteractionCreate type InteractionCreate struct { @@ -62,3 +76,44 @@ func (i *InteractionCreate) Update(data *discordgo.InteractionResponseData) { Data: data, }) } + +func (i *InteractionCreate) ShowModal(data *ModalData) error { + var reqData struct { + Type discordgo.InteractionResponseType `json:"type"` + Data ModalData `json:"data"` + } + + reqData.Type = discordgo.InteractionResponseModal + reqData.Data = *data + bin, err := json.Marshal(reqData) + if err != nil { + return err + } + + buf := bytes.NewBuffer(bin) + + req, err := http.NewRequest("POST", discordgo.EndpointInteractionResponse(i.ID, i.Token), buf) + if err != nil { + return err + } + + req.Header.Add("Authorization", i.Session.Identify.Token) + req.Header.Add("Content-Type", "application/json") + + resp, err := i.Session.Client.Do(req) + if err != nil { + return err + } + + respBin, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + if resp.StatusCode != 200 { + return fmt.Errorf("%s", string(respBin)) + } + + defer resp.Body.Close() + return nil +} diff --git a/utils/paginationEmbed.go b/utils/paginationEmbed.go new file mode 100644 index 0000000..4e9d4b0 --- /dev/null +++ b/utils/paginationEmbed.go @@ -0,0 +1,225 @@ +package utils + +import ( + "fmt" + "math/rand" + + "github.com/bwmarrin/discordgo" +) + +// PaginationEmbed is embed with page +type PaginationEmbed struct { + Embed *discordgo.MessageEmbed + Data []string + Current int + Total int + id string + desc string +} + +var PaginationEmbeds = make(map[string]*PaginationEmbed) + +func makeComponents(id string, current, total int) *[]discordgo.MessageComponent { + disabled := false + + if total == 1 { + disabled = true + } + + return &[]discordgo.MessageComponent{ + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.Button{ + Style: discordgo.PrimaryButton, + Label: "이전", + CustomID: MakePaginationEmbedPrev(id), + Disabled: disabled, + }, + discordgo.Button{ + Style: discordgo.SecondaryButton, + Label: fmt.Sprintf("(%d/%d)", current, total), + CustomID: MakePaginationEmbedPages(id), + Disabled: disabled, + }, + discordgo.Button{ + Style: discordgo.PrimaryButton, + Label: "다음", + CustomID: MakePaginationEmbedNext(id), + Disabled: disabled, + }, + }, + }, + } +} + +func makeDesc(desc, item string) string { + var newDesc string + + if desc == "" { + newDesc = item + } else { + newDesc = fmt.Sprintf(desc, item) + } + return newDesc +} + +// StartPaginationEmbed starts new PaginationEmbed struct +func StartPaginationEmbed(s *discordgo.Session, m any, e *discordgo.MessageEmbed, data []string, defaultDesc string) { + var userId string + + switch m := m.(type) { + case *discordgo.MessageCreate: + userId = m.Author.ID + case *InteractionCreate: + userId = m.Member.User.ID + } + + id := fmt.Sprintf("%s/%d", userId, rand.Intn(12)) + p := &PaginationEmbed{ + Embed: e, + Data: data, + Current: 1, + Total: len(data), + id: id, + desc: defaultDesc, + } + + if len(data) <= 0 { + p.Embed.Description = makeDesc(p.desc, "없음") + p.Total = 1 + } else { + p.Embed.Description = makeDesc(p.desc, data[0]) + } + + switch m := m.(type) { + case *discordgo.MessageCreate: + s.ChannelMessageSendComplex(m.ChannelID, &discordgo.MessageSend{ + Reference: m.Reference(), + Embeds: []*discordgo.MessageEmbed{p.Embed}, + Components: *makeComponents(id, p.Current, p.Total), + }) + case *InteractionCreate: + m.EditReply(&discordgo.WebhookEdit{ + Embeds: &[]*discordgo.MessageEmbed{p.Embed}, + Components: makeComponents(id, p.Current, p.Total), + }) + } + + PaginationEmbeds[id] = p +} + +func GetPaginationEmbed(id string) *PaginationEmbed { + if p, ok := PaginationEmbeds[id]; ok { + return p + } + return nil +} + +func (p *PaginationEmbed) Prev(i *InteractionCreate) { + if p.Current == 1 { + i.Reply(&discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "❌ 오류", + Description: "해당 페이지가 처음ㅇ이에요.", + Color: EmbedFail, + }, + }, + Flags: discordgo.MessageFlagsEphemeral, + }) + return + } + + p.Current -= 1 + + p.Embed.Description = makeDesc(p.desc, p.Data[p.Current-1]) + + i.Update(&discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{p.Embed}, + Components: *makeComponents(p.id, p.Current, p.Total), + }) +} + +func (p *PaginationEmbed) Next(i *InteractionCreate) { + if p.Current >= p.Total { + i.Reply(&discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "❌ 오류", + Description: "해당 페이지가 마지막ㅇ이에요.", + Color: EmbedFail, + }, + }, + Flags: discordgo.MessageFlagsEphemeral, + }) + return + } + + p.Current += 1 + + p.Embed.Description = makeDesc(p.desc, p.Data[p.Current-1]) + + i.Update(&discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{p.Embed}, + Components: *makeComponents(p.id, p.Current, p.Total), + }) +} + +func (p *PaginationEmbed) Set(i *InteractionCreate, page int) { + if page <= 0 { + i.Reply(&discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "❌ 오류", + Description: "해당 값은 0보다 커야해요.", + Color: EmbedFail, + }, + }, + Flags: discordgo.MessageFlagsEphemeral, + }) + return + } + + if page >= p.Total { + i.Reply(&discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{ + { + Title: "❌ 오류", + Description: "해당 값은 총 페이지의 수보다 작아야해요.", + Color: EmbedFail, + }, + }, + Flags: discordgo.MessageFlagsEphemeral, + }) + return + } + + p.Current = page + + p.Embed.Description = makeDesc(p.desc, p.Data[p.Current-1]) + + i.Update(&discordgo.InteractionResponseData{ + Embeds: []*discordgo.MessageEmbed{p.Embed}, + Components: *makeComponents(p.id, p.Current, p.Total), + }) +} + +func (p *PaginationEmbed) ShowModal(i *InteractionCreate) { + i.ShowModal(&ModalData{ + CustomId: MakePaginationEmbedModal(p.id), + Title: fmt.Sprintf("%s의 리스트", i.Session.State.User.Username), + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.TextInput{ + CustomID: MakePaginationEmbedSetPage(p.id), + Label: "페이지", + Style: discordgo.TextInputShort, + Placeholder: "이동할 페이지를 여기에 적어주세요.", + Required: true, + }, + }, + }, + }, + }) +} diff --git a/utils/regexp.go b/utils/regexp.go index d2279e3..a5c7e0f 100644 --- a/utils/regexp.go +++ b/utils/regexp.go @@ -2,6 +2,13 @@ package utils import "regexp" -var FlexibleStringParser *regexp.Regexp = regexp.MustCompile("[^\\s\"'「」«»]+|\"([^\"]*)\"|'([^']*)'|「([^」]*)」|«([^»]*)»") -var Decimals *regexp.Regexp = regexp.MustCompile(`\d+`) -var ItemIdRegexp *regexp.Regexp = regexp.MustCompile(`No.\d+`) +var ( + RegexpFlexibleString = regexp.MustCompile(`[^\s"'「」«»]+|"([^"]*)"|'([^']*)'|「([^」]*)」|«([^»]*)»`) + RegexpDecimals = regexp.MustCompile(`\d+`) + RegexpItemId = regexp.MustCompile(`No.\d+`) + RegexpEmoji = regexp.MustCompile(``) + RegexpLearnQueryCommand = regexp.MustCompile(`단어:([^\n대답개수:]*)`) + RegexpLearnQueryResult = regexp.MustCompile(`대답:([^\n단어개수:]*)`) + RegexpLearnQueryLength = regexp.MustCompile(`개수:(\d+)`) + RegexpPaginationEmbedId = regexp.MustCompile(`^(\d+)/(\d+)$`) +)