diff --git a/.gitignore b/.gitignore index 22ed4fe..622605e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .vscode/ +.idea/ +.github +docker/ **/**/*.env -docker/** \ No newline at end of file diff --git a/adapters/adapters.go b/adapters/adapters.go new file mode 100644 index 0000000..44da1e6 --- /dev/null +++ b/adapters/adapters.go @@ -0,0 +1,22 @@ +package adapters + +import "ticket-pimp/internal/domain" + +type IDummyTelegram interface { + DummyNotification(id string, text string) +} + +type IGit interface { + CreateRepo(name string) (*domain.Git, error) +} + +type ICloud interface { + CreateFolder(name string) domain.Response +} + +type ICoda interface { + ListDocs() + CreateApp(task domain.CodaApplication) (string, error) + CreateTask(title string, desc string, creatorName string, creatorID string) (string, error) + GetRowLink(id string) (string, error) +} diff --git a/client/discord/discord.go b/client/discord/discord.go index b540b34..d1dbbf4 100644 --- a/client/discord/discord.go +++ b/client/discord/discord.go @@ -1,18 +1,127 @@ package discord import ( - "errors" "fmt" "log" "os" "os/signal" - "ticket-pimp/client/discord/handler" + "ticket-pimp/client/discord/discord_handler" + "ticket-pimp/client/discord/discord_router" + "ticket-pimp/internal/controller" "ticket-pimp/internal/domain" + "ticket-pimp/internal/external" "github.com/bwmarrin/discordgo" ) +var ( + minLength int = 3 + repoType string = "repo_type" + projectRepo string = "project_repo" + buildRepo string = "build_repo" + nameOption string = "name" + tagsPreset = [3]discordgo.ForumTag{ + { + Name: "В работе", + Moderated: true, + EmojiName: "👩‍🍳", + }, + { + Name: "Готово", + Moderated: true, + EmojiName: "✅", + }, + { + Name: "Не начат", + Moderated: true, + EmojiName: "🚧", + }, + } + commands = []discordgo.ApplicationCommand{ + { + Name: "ping", + Description: "pongs in a reply", + }, + { + Name: "coda_ticket", + Description: "Creates ticket in Coda.io w/ provided info", + }, + { + Name: "init_project", + Description: "Connect project with Coda ID", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "key", + Description: "Project's key from Coda.io", + Required: true, + MinLength: &minLength, + }, + }, + }, + { + Name: "project", + Description: "Create new development ticket", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: "project_name", + Description: "Temporary project name", + Required: true, + MinLength: &minLength, + }, + }, + }, + { + Name: "info", + Description: "Get project's info", + }, + { + Name: "repo", + Description: "Creates repository of selected type. Name used for projects channels only", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: repoType, + Description: "The type of repo", + Required: true, + Choices: []*discordgo.ApplicationCommandOptionChoice{ + { + Name: "Unity project repo", + Value: projectRepo, + }, + { + Name: "XCode build repo", + Value: buildRepo, + }, + }, + }, + { + Type: discordgo.ApplicationCommandOptionString, + Name: nameOption, + Description: "Type the repository's name", + Required: false, + MinLength: &minLength, + }, + }, + }, + { + Name: "folder", + Description: "Command for cloud folder creation", + Options: []*discordgo.ApplicationCommandOption{ + { + Type: discordgo.ApplicationCommandOptionString, + Name: nameOption, + Description: "Type the folder's name", + Required: false, + MinLength: &minLength, + }, + }, + }, + } +) + func initBotWith(token string) *discordgo.Session { discord, err := discordgo.New("Bot " + token) if err != nil { @@ -27,95 +136,137 @@ type DiscordOptions struct { Controller *controller.WorkflowController } -func checkPrivateMessaging(s *discordgo.Session, i *discordgo.InteractionCreate) error { - dchan, err := s.Channel(i.ChannelID) +func updateForum(conf *domain.Config, s *discordgo.Session) ([]discordgo.ForumTag, error) { + + log.Println("Updating forum chan...") + + // Get tasks channel instance: + forum, err := s.Channel(conf.Discord.IsTaskForum) if err != nil { - return err + return nil, err } - if dchan.Type == discordgo.ChannelTypeDM { - s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Content: "Yo, fella! I'm not working in private!", - }, - }) - return errors.New("no private messages! lol") + // Map all pre-set tags + var tagsMap = map[string]discordgo.ForumTag{} + for _, t := range forum.AvailableTags { + tagsMap[t.Name] = t } - return nil + // Result tags array + tags := forum.AvailableTags + + // Check if preset tag exists into current channel.. + for i := 0; i < len(tagsPreset); i++ { + _, ok := tagsMap[tagsPreset[i].Name] + if !ok { + // .. and append them if they aren't + tags = append(tags, tagsPreset[i]) + } + } + + dchan, err := s.ChannelEditComplex(forum.ID, &discordgo.ChannelEdit{ + AvailableTags: &tags, + }) + if err != nil { + return nil, err + } + + log.Printf("Channel %s with ID %s propagated by tags:", dchan.Name, dchan.ID) + for _, t := range dchan.AvailableTags { + fmt.Printf("N: %s, ID: %s", t.Name, t.ID) + } + + // Update config w/ tags: + confTags := make(map[domain.TaskState]string) + + for _, tag := range dchan.AvailableTags { + switch tag.Name { + case "Не начат": + confTags[domain.State(0)] = tag.ID + case "В работе": + confTags[domain.State(1)] = tag.ID + case "Готово": + confTags[domain.State(2)] = tag.ID + } + } + + conf.Discord.Tags = confTags + + return dchan.AvailableTags, nil } -func Run(conf domain.Config, opts DiscordOptions) error { - token := conf.Discord.Token - - s := initBotWith(token) - - router := handler.InitRouter(*opts.Controller, &conf.Discord, &conf.Telegram) - - commandHandlers := map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){} - for _, handler := range router.Commands { - commandHandlers[handler.Command.Name] = handler.Handler - } - - componentsHandlers := map[string]func(s *discordgo.Session, i *discordgo.InteractionCreate){ - "task_start": router.Components[0].Handler, - "task_close": router.Components[0].Handler, - } - - s.AddHandler(router.ListenPosts) - - s.AddHandler(func(s *discordgo.Session, i *discordgo.InteractionCreate) { - err := checkPrivateMessaging(s, i) +func commandRegistration(s *discordgo.Session, commands []discordgo.ApplicationCommand) []*discordgo.ApplicationCommand { + log.Println("Adding commands...") + var cmds []*discordgo.ApplicationCommand + for _, cmd := range commands { + cmd, err := s.ApplicationCommandCreate(s.State.User.ID, "", &cmd) if err != nil { - return + log.Panicf("Cannot create '%v' command: %v", cmd.Name, err) } + cmds = append(cmds, cmd) + log.Println(cmd.Name + " command added") + } + return cmds +} - switch i.Type { - case discordgo.InteractionApplicationCommand: - if h, ok := commandHandlers[i.ApplicationCommandData().Name]; ok { - h(s, i) - } - case discordgo.InteractionMessageComponent: - if h, ok := componentsHandlers[i.MessageComponentData().CustomID]; ok { - h(s, i) - } - } - }) +func Run(conf *domain.Config, opts DiscordOptions) error { + // bot init + s := initBotWith(conf.Discord.Token) + + // Init new handler + h := discord_handler.New( + opts.Controller, + &conf.Discord, + external.NewDummyClient(conf.Telegram), + ) + + r := discord_router.NewApp(s) + + var commonMw = []discord_router.Middleware{ + h.WithInitialResponse, + h.RejectPM, + } + + r.Use(commonMw...). + Route("ping", h.Ping). + Route("project", h.CreateProject). + Route("info", h.ProjectInfo). + Route("repo", h.CreateGit). + Route("folder", h.CreateFolder). + Route("init_project", h.InitChannelAsProject). + Route("coda_ticket", h.CreateCoda) + + // and components + r. + /*Use().*/ // Combining into group duplicates replies + Route("task_start", h.HandleTaskButtons). + Route("task_close", h.HandleTaskButtons) + + // Add posts listener + s.AddHandler(h.ListenPosts) + + // Add interactions handlers + s.AddHandler(r.Serve) + + // session opening if err := s.Open(); err != nil { return fmt.Errorf("cannot open the session: %v", err) } - // UPDATE FORUM IF NEEDED: - - forum, err := s.Channel(conf.Discord.IsProjectChannel) + // Forum update + tags, err := updateForum(conf, s) if err != nil { - log.Print(err) + log.Println(err.Error()) } - _, err = s.ChannelEditComplex(forum.ID, &discordgo.ChannelEdit{ - AvailableTags: &router.Tags, - }) - if err != nil { - log.Fatal(err) - } + //Update handler with tags: + h.SetAvailableTags(tags) - log.Println("Adding commands...") - var cmds []*discordgo.ApplicationCommand - var logString []string - for _, h := range router.Commands { - cmd, err := s.ApplicationCommandCreate(s.State.User.ID, "", &h.Command) - if err != nil { - log.Panicf("Cannot create '%v' command: %v", h.Command.Name, err) - } - cmds = append(cmds, cmd) - logString = append(logString, cmd.Name) - } - - log.Println("Following commands added:") - log.Println(logString) + // commands registration + cmds := commandRegistration(s, commands) + // gracefull shutdown defer s.Close() stop := make(chan os.Signal, 1) signal.Notify(stop, os.Interrupt) diff --git a/client/discord/discord_handler/common.go b/client/discord/discord_handler/common.go new file mode 100644 index 0000000..3519c1f --- /dev/null +++ b/client/discord/discord_handler/common.go @@ -0,0 +1,52 @@ +package discord_handler + +import ( + "github.com/bwmarrin/discordgo" +) + +func (h *Handler) defaultFollowUp(answer string, s *discordgo.Session, i *discordgo.InteractionCreate) { + + // Sending result: + /*_, err := */ + s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{ + Content: answer, + }) + + // if err != nil { + // s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{ + // Content: fmt.Sprintf("Something went wrong: %v", err), + // }) + // return + // } +} + +// setFlag +// sets tag with In progress and Done text to discords channel; +func (h *Handler) setFlag(s *discordgo.Session, i *discordgo.InteractionCreate, tag *discordgo.ForumTag) error { + + th, err := s.Channel(i.ChannelID) + if err != nil { + return err + } + + forum, err := s.Channel(th.ParentID) + if err != nil { + return err + } + + // Проверка на существование тега в списке тегов: + if len(forum.AvailableTags) != 0 { + for _, some := range forum.AvailableTags { + if some.Name == tag.Name { + _, err := s.ChannelEditComplex(i.ChannelID, &discordgo.ChannelEdit{ + AppliedTags: &[]string{some.ID}, + }) + if err != nil { + return err + } + } + } + } + + return nil +} diff --git a/client/discord/discord_handler/discord_handler.go b/client/discord/discord_handler/discord_handler.go new file mode 100644 index 0000000..a94ebbf --- /dev/null +++ b/client/discord/discord_handler/discord_handler.go @@ -0,0 +1,648 @@ +package discord_handler + +import ( + "context" + "fmt" + "log" + "ticket-pimp/adapters" + router "ticket-pimp/client/discord/discord_router" + "ticket-pimp/internal/controller" + "ticket-pimp/internal/domain" + "ticket-pimp/internal/helpers" + + "github.com/bwmarrin/discordgo" +) + +type Handler struct { + controller *controller.WorkflowController + conf *domain.DiscordConfig + telegramDummyClient adapters.IDummyTelegram + tags []discordgo.ForumTag +} + +func New( + controller *controller.WorkflowController, + conf *domain.DiscordConfig, + tg adapters.IDummyTelegram, +) *Handler { + return &Handler{ + controller: controller, + conf: conf, + telegramDummyClient: tg, + } +} + +func (h *Handler) RejectPM(f router.HandlerFunc) router.HandlerFunc { + return func(s *discordgo.Session, i *discordgo.InteractionCreate) { + + dchan, err := s.Channel(i.ChannelID) + if err != nil { + log.Println(err) + return + } + + if dchan.Type != discordgo.ChannelTypeDM { + f(s, i) + return + } + + respondWithReject(s, i) + } +} + +func respondWithReject(s *discordgo.Session, i *discordgo.InteractionCreate) { + + var content = "Yo, fella! I'm not working in private!" + + err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Content: content, + }, + }) + + if err == nil { + return + } + + _, err = s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: &content, + }) + + if err != nil { + log.Printf("unable to edit answer with unknown error %v", err) + } +} + +// Моментальный ответ для избежания столкновения с протуханием токена +func (h *Handler) WithInitialResponse(f router.HandlerFunc) router.HandlerFunc { + return func(s *discordgo.Session, i *discordgo.InteractionCreate) { + + initialResponse := discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Flags: discordgo.MessageFlagsEphemeral, + Content: "👩‍🍳 Cooking your query..", + }, + } + + err := s.InteractionRespond(i.Interaction, &initialResponse) + if err != nil { + log.Println(err) + } + + f(s, i) + } +} + +func (h *Handler) SetAvailableTags(tags []discordgo.ForumTag) { + h.tags = append(h.tags, tags...) +} + +func (h *Handler) Ping(s *discordgo.Session, i *discordgo.InteractionCreate) { + + var content string = fmt.Sprintf( + "**Pong to:** %s\n**App ID:** %s\n**Guild ID:** %s\nC**hannelID:** %s", + i.Member.User.Mention(), + i.AppID, + i.GuildID, + i.ChannelID, + ) + + _, err := s.InteractionResponseEdit(i.Interaction, &discordgo.WebhookEdit{ + Content: &content, + }) + + if err != nil { + log.Println(err) + } +} + +// ListenPosts +/* + ..listens to new posts in specific channel + to act them like a task +*/ +func (h *Handler) ListenPosts(s *discordgo.Session, th *discordgo.ThreadCreate) { + + // Check if thread starter is not a bot, and thread started at the tasks channel; + if th.ParentID != h.conf.IsTaskForum || th.OwnerID == s.State.User.ID { + return + } + + // Get all messages from the channel: + msgs, _ := s.ChannelMessages(th.ID, 1, "", "", "") + + // Take the first one: + msg, _ := s.ChannelMessage(th.ID, msgs[0].ID) + + if msg.Author.ID == s.State.User.ID { + return + } + + content := th.Name + content += "\n" + msg.Content + + user, _ := s.GuildMember(th.GuildID, msg.Author.ID) + + t, err := h.controller.WriteTaskToDB(&domain.Task{ + Description: content, + Creator: user.User.Mention(), + }) + if err != nil { + s.ChannelMessageSend(th.ID, fmt.Sprintf("unable to write task to db, %v", err)) + return + } + + // Отредактировать Thread name как для задачи + appliedTags := []string{ + h.conf.Tags[domain.NewTaskState()], + // h.tags[2].ID, + } + + _, err = s.ChannelEditComplex(th.ID, &discordgo.ChannelEdit{ + Name: fmt.Sprintf("Task ID: %d, by %s", t.ID, t.Creator), + AppliedTags: &appliedTags, + }) + if err != nil { + log.Printf("th edition is not complete: %v", err) + } + + // Fix the original task message: + taskMessage, err := s.ChannelMessageSendComplex(th.ID, &discordgo.MessageSend{ + Content: content, + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.Button{ + Label: "Start", + Style: discordgo.SuccessButton, + Disabled: false, + CustomID: "task_start", + }, + discordgo.Button{ + Label: "Close", + Style: discordgo.DangerButton, + Disabled: true, + CustomID: "task_close", + }, + }, + }, + }, + }) + if err != nil { + log.Printf("th start message edition is not complete: %v", err) + } + + err = h.controller.UpdateTasksMessageID(context.TODO(), taskMessage.ID, t.ID) + if err != nil { + s.ChannelMessageSend(th.ID, fmt.Sprintf("unable to update task at the db, %v", err)) + return + } +} + +// handleTaskButtons +// .. handler function to work with the Action Buttons over a task +func (h *Handler) HandleTaskButtons(s *discordgo.Session, i *discordgo.InteractionCreate) { + + err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{}, + }) + _ = err + + // Get assignee value; --------------------------------------------------------------------------------- + user := i.Member.User.Mention() + + var ( + opt int = -1 + doneButtonIsDisabled bool = false + state domain.TaskState = domain.NewTaskState() + message string + tag discordgo.ForumTag + ) + + // Check what flow was touched: ------------------------------------------------------------------------- + switch i.Interaction.MessageComponentData().CustomID { + case "task_start": + opt = 0 + doneButtonIsDisabled = false + state = domain.InrpogressTaskState() + message = "взята в работу" + tag = h.tags[0] + case "task_close": + opt = 1 + doneButtonIsDisabled = true + state = domain.DoneTaskState() + message = "выполнена" + tag = h.tags[1] + } + + // Send the task update to db -------------------------------------------------------------------------- + convertable, err := h.controller.UpdateTask(i.Message.ID, opt, user) + if err != nil { + s.ChannelMessageSend(i.ChannelID, fmt.Sprintf("Unable to update task at the db w/ error: %v", err)) + return + } + + // Map DB's response to domain.Task: ------------------------------------------------------------------- + task := convertable. + ExtractDomain() + + newContent := task.DiscordMessage(state) + + // Send message to the creator in Telegram: ------------------------------------------------------------- + + if task.CreatorLink != "" { + h.telegramDummyClient.DummyNotification( + task.CreatorLink, + fmt.Sprintf("Task ID: %d %s", task.ID, message)) + } + + // Send a message to the thread about the task was started: --------------------------------------------- + _, err = s.ChannelMessageSendComplex(i.ChannelID, &discordgo.MessageSend{ + Content: newContent, + }) + if err != nil { + log.Printf("error while sending start task message: %v", err) + } + + // Fix the original task message: ---------------------------------------------------------------------- + _, err = s.ChannelMessageEditComplex(&discordgo.MessageEdit{ + Channel: i.ChannelID, + ID: i.Message.ID, + Components: []discordgo.MessageComponent{ + discordgo.ActionsRow{ + Components: []discordgo.MessageComponent{ + discordgo.Button{ + Label: "Close", + Style: discordgo.DangerButton, + Disabled: doneButtonIsDisabled, + CustomID: "task_close", + }, + }, + }, + }, + }) + if err != nil { + log.Printf("th start message edition is not complete: %v", err) + } + + err = h.setFlag(s, i, &tag) + if err != nil { + log.Printf("error while `start` tag setting: %v", err) + } +} + +// CreateFolder +/* + - creates project's cloud folder; + - writed folder link to db; +*/ +func (h *Handler) CreateFolder(s *discordgo.Session, i *discordgo.InteractionCreate) { + const ( + nameOption string = "folder_name" + ) + + // Моментальный ответ для избежания столкновения с протуханием токена + initialResponse := discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Flags: discordgo.MessageFlagsEphemeral, + Content: "👩‍🍳 Cooking your query..", + }, + } + + s.InteractionRespond(i.Interaction, &initialResponse) + + // Определение переменной для ответа + var result string = "unexpected result" + + // Определение выбранных вариантов ответа + options := i.ApplicationCommandData().Options + + optionMap := make(map[string]*discordgo.ApplicationCommandInteractionDataOption, len(options)) + for _, opt := range options { + optionMap[opt.Name] = opt + } + + // Creating request: + var req controller.FolderRequest + name, insertedValueNotNil := optionMap[nameOption] + dchan, err := s.Channel(i.ChannelID) + + if err != nil { + log.Printf("error while identifying channel: %v", err) + } else { + + if dchan.ParentID == h.conf.IsProjectChannel { + req.ChannelID = dchan.ID + if insertedValueNotNil { + req.InsertedName = name.StringValue() + } + + } else { + req.ChannelID = "" + if insertedValueNotNil { + req.InsertedName = name.StringValue() + } + } + } + + // Making request: + resp := h.controller.CreateFolder(context.TODO(), req) + if resp.Project == nil { + result = "Надо написать имя для папки, или создать папку из проекта!" + } else { + result = resp.Project.DiscordString() + if resp.Message != nil { + result += "Errors: " + resp.Message.Error() + } + } + + h.defaultFollowUp(result, s, i) +} + +// CreateGit +/* + -creates project's git repository; + - writed git link to db; +*/ +func (h *Handler) CreateGit(s *discordgo.Session, i *discordgo.InteractionCreate) { + + const ( + typeOfRepo = "repo_type" + projectRepoType = "project_repo" + buildRepoType = "build_repo" + nameOption = "repo_name" + ) + + // Моментальный ответ для избежания столкновения с протуханием токена + initialResponse := discordgo.InteractionResponse{ + Type: discordgo.InteractionResponseChannelMessageWithSource, + Data: &discordgo.InteractionResponseData{ + Flags: discordgo.MessageFlagsEphemeral, + Content: "👩‍🍳 Cooking your query..", + }, + } + + s.InteractionRespond(i.Interaction, &initialResponse) + + // Определение переменной для ответа + var result string = "unexpected result" + + // Access options in the order provided by the user. + options := i.ApplicationCommandData().Options + + // Or convert the slice into a map + optionMap := make(map[string]*discordgo.ApplicationCommandInteractionDataOption, len(options)) + for _, opt := range options { + optionMap[opt.Name] = opt + } + + // Creating request: + var req controller.GitRequest + + name, insertedValueNotNil := optionMap[nameOption] + + isBuild := optionMap[typeOfRepo] + + switch isBuild.StringValue() { + case buildRepoType: + req.IsBuildGit = true + case projectRepoType: + req.IsBuildGit = false + } + + dchan, err := s.Channel(i.ChannelID) + if err != nil { + h.defaultFollowUp("error while identifying channel: %v", s, i) + return + } else { + + if dchan.ParentID == h.conf.IsProjectChannel { + req.ChannelID = dchan.ID + if insertedValueNotNil { + req.InsertedName = name.StringValue() + } + } else { + req.ChannelID = "" + if insertedValueNotNil { + req.InsertedName = name.StringValue() + } + } + } + + // Making request: + resp := h.controller.CreateGit(context.TODO(), req) + if resp.Project == nil { + if resp.Message != nil { + result = resp.Message.Error() + } + } else { + + result = resp.Project.DiscordString() + if resp.Message != nil { + result += "Errors: " + resp.Message.Error() + } + + } + h.defaultFollowUp(result, s, i) +} + +// ProjectInfo +/* + Message in chat with related project information +*/ +func (h *Handler) ProjectInfo(s *discordgo.Session, i *discordgo.InteractionCreate) { + + var result string + + // Get channel from the request + dchan, err := s.Channel(i.ChannelID) + if err != nil { + result = "unable to get channel from the message" + } else { + project, err := h.controller.GetProjectByChannelID(context.TODO(), dchan.ID) + if err != nil { + result = err.Error() + } else { + if project != nil { + result = project.DiscordString() + if err != nil { + result += "Errors: " + err.Error() + } + } else { + result = "Something wrong with retrieving project from db" + } + + } + } + + h.defaultFollowUp(result, s, i) +} + +// InitChannelAsProject +/* + - makes channel-project raw in the db storage; +*/ +func (h *Handler) InitChannelAsProject(s *discordgo.Session, i *discordgo.InteractionCreate) { + + var result string + + // Get channel from the request + dchan, err := s.Channel(i.ChannelID) + if err != nil { + result = "unable to get channel from the message" + } else { + if dchan.ParentID != h.conf.IsProjectChannel { + // Sending result: + _, err := s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{ + Content: "This channel is not at the project's group", + }) + + if err != nil { + s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{ + Content: fmt.Sprintf("Something went wrong: %v", err), + }) + return + } + return + } + + // Access options in the order provided by the user. + options := i.ApplicationCommandData().Options + + // Or convert the slice into a map + optionMap := make(map[string]*discordgo.ApplicationCommandInteractionDataOption, len(options)) + for _, opt := range options { + optionMap[opt.Name] = opt + } + + if option, ok := optionMap["key"]; ok { + var errMsg error = nil + + project, err := h.controller.InitProjectInChannel(context.TODO(), i.ChannelID, option.StringValue()) + if err != nil { + result = fmt.Sprintf("unable to init project: %v", err) + } else { + result = project.DiscordString() + if errMsg != nil { + result += "Errors: " + errMsg.Error() + } + } + } + } + + h.defaultFollowUp(result, s, i) +} + +// CreateProject +/* + - creates new proejct in the db; + - creates new channel and writes it to db; +*/ +func (h *Handler) CreateProject(s *discordgo.Session, i *discordgo.InteractionCreate) { + + var result string + // Access options in the order provided by the user. + options := i.ApplicationCommandData().Options + + // Or convert the slice into a map + optionMap := make(map[string]*discordgo.ApplicationCommandInteractionDataOption, len(options)) + for _, opt := range options { + optionMap[opt.Name] = opt + } + + // Get Project's title from the request: + var projectTitle string + if option, ok := optionMap["project_name"]; !ok { + return + } else { + projectTitle = option.StringValue() + } + + // Create channel with specified title + dchan, err := s.GuildChannelCreate(i.GuildID, projectTitle, discordgo.ChannelTypeGuildText) + if err != nil { + result = fmt.Sprintf("chan creation problem: %v\n", err) + h.defaultFollowUp(result, s, i) + return + } + + // Create DB raw with new project: + p, err := h.controller.ProjectCreate(context.TODO(), domain.Project{ + ChannelID: dchan.ID, + Name: projectTitle, + }) + + if err != nil { + result = fmt.Sprintf("unable to create project: %v\n", err) + + // Revert channel creation: + _, err := s.ChannelDelete(dchan.ID) + if err != nil { + result += fmt.Sprintf("\nunable to clean channel: %v\n", err) + } + + h.defaultFollowUp(result, s, i) + return + } + + // Edit created channel: + edit := discordgo.ChannelEdit{ + Name: p.Key + "-" + helpers.Cut(projectTitle), + ParentID: h.conf.IsProjectChannel, + } + + dchan, err = s.ChannelEdit(dchan.ID, &edit) + if err != nil { + result = fmt.Sprintf("channel %s created, but unable to edit follow up message: %v\n", p.Key, err) + h.defaultFollowUp(result, s, i) + return + + } + + // Отправить сообщение о создании проекта: + _, err = s.ChannelMessageSend(dchan.ID, p.DiscordString()) + if err != nil { + log.Printf("message send problem: %v\n", err) + result = "Project was created, but there is some problem with init channel message" + h.defaultFollowUp(result, s, i) + } + result = fmt.Sprintf("Project was created: https://discord.com/channels/%s/%s", i.GuildID, dchan.ID) + + h.defaultFollowUp(result, s, i) +} + +// CreateCoda +/* + - sends request to Coda.io; +*/ +func (h *Handler) CreateCoda(s *discordgo.Session, i *discordgo.InteractionCreate) { + + // Get channel from the request + dchan, err := s.Channel(i.ChannelID) + if err != nil { + h.defaultFollowUp("unable to get channel from the message", s, i) + return + } + + if dchan.ParentID != h.conf.IsProjectChannel { + h.defaultFollowUp("This channel is not at the project's group", s, i) + return + } + + //[ ] достать проект из базы и послать в коду + result, err := h.controller.CreateCoda(i.GuildID, dchan.ID) + if err != nil { + h.defaultFollowUp(fmt.Sprintf("unable to create coda: %v", err), s, i) + return + } + + if err != nil { + result += fmt.Sprintf("\nexecuted w/ error: %v", err) + } + + h.defaultFollowUp(result, s, i) +} diff --git a/client/discord/discord_router/router.go b/client/discord/discord_router/router.go new file mode 100644 index 0000000..a6e1de3 --- /dev/null +++ b/client/discord/discord_router/router.go @@ -0,0 +1,101 @@ +package discord_router + +import "github.com/bwmarrin/discordgo" + +type HandlerFunc func(*discordgo.Session, *discordgo.InteractionCreate) + +type RouteEntry struct { + CommandName string + Handler HandlerFunc +} + +func (re *RouteEntry) Match(i *discordgo.InteractionCreate) bool { + switch i.Type { + case discordgo.InteractionApplicationCommand: + if i.ApplicationCommandData().Name != re.CommandName { + return false + } + case discordgo.InteractionMessageComponent: + if i.MessageComponentData().CustomID != re.CommandName { + return false + } + } + + return true +} + +type Router struct { + session *discordgo.Session + routes []RouteEntry + group []Group +} + +func NewApp(s *discordgo.Session) *Router { + return &Router{ + session: s, + } +} + +func (r *Router) Route(cmd string, handlerFunc HandlerFunc) *Router { + + r.routes = append(r.routes, RouteEntry{ + CommandName: cmd, + Handler: handlerFunc, + }) + + return r +} + +func (r *Router) Serve(s *discordgo.Session, i *discordgo.InteractionCreate) { + for _, e := range r.routes { + ok := e.Match(i) + if ok { + e.Handler(s, i) + } + } + + for _, g := range r.group { + for _, e := range g.routes { + ok := e.Match(i) + if ok { + if len(g.middleware) < 1 { + e.Handler(s, i) + } + + wrapped := e.Handler + + // loop in reverse to preserve middleware order + for i := len(g.middleware) - 1; i >= 0; i-- { + wrapped = g.middleware[i](wrapped) + } + wrapped(s, i) + } + } + } +} + +type Middleware func(HandlerFunc) HandlerFunc + +type Group struct { + routes []RouteEntry + middleware []Middleware +} + +func (r *Router) Use(m ...Middleware) *Group { + + r.group = append(r.group, Group{ + routes: []RouteEntry{}, + middleware: m, + }) + + return &r.group[len(r.group)-1] +} + +func (g *Group) Route(cmd string, handlerFunc HandlerFunc) *Group { + + g.routes = append(g.routes, RouteEntry{ + CommandName: cmd, + Handler: handlerFunc, + }) + return g +} diff --git a/client/discord/handler/handle_external_task.go b/client/discord/handler/handle_external_task.go deleted file mode 100644 index b41dd5c..0000000 --- a/client/discord/handler/handle_external_task.go +++ /dev/null @@ -1,249 +0,0 @@ -package handler - -import ( - "context" - "fmt" - "log" - "ticket-pimp/internal/domain" - - "github.com/bwmarrin/discordgo" - "github.com/imroc/req/v3" -) - -func (c *client) setFlag(s *discordgo.Session, i *discordgo.InteractionCreate, tag *discordgo.ForumTag) error { - - th, err := s.Channel(i.ChannelID) - if err != nil { - return err - } - - forum, err := s.Channel(th.ParentID) - if err != nil { - return err - } - - // Проверка на существование тега в списке тегов: - if len(forum.AvailableTags) != 0 { - for _, some := range forum.AvailableTags { - if some.Name == tag.Name { - _, err := s.ChannelEditComplex(i.ChannelID, &discordgo.ChannelEdit{ - AppliedTags: &[]string{some.ID}, - }) - if err != nil { - return err - } - } - } - } - - return nil -} - -func (c *client) ListenPosts(s *discordgo.Session, th *discordgo.ThreadCreate) { - - // Check if thread starter is not a bot, and thread started at the tasks channel; - if th.ParentID != c.conf.IsTaskForum || th.OwnerID == s.State.User.ID { - return - } - - msgs, _ := s.ChannelMessages(th.ID, 1, "", "", "") - - msg, _ := s.ChannelMessage(th.ID, msgs[0].ID) - - if msg.Author.ID == s.State.User.ID { - return - } - - content := th.Name - content += "\n" + msg.Content - - user, _ := s.GuildMember(th.GuildID, msg.Author.ID) - - t, err := c.controller.WriteTaskToDB(&domain.Task{ - Description: content, - Creator: user.User.Mention(), - }) - if err != nil { - s.ChannelMessageSend(th.ID, fmt.Sprintf("unable to write task to db, %v", err)) - return - } - - // [x] -- Отредактировать Thread name как для задачи - _, err = s.ChannelEditComplex(th.ID, &discordgo.ChannelEdit{ - Name: fmt.Sprintf("Task ID: %d, by %s", t.ID, t.Creator), - }) - if err != nil { - log.Printf("th edition is not complete: %v", err) - } - - // Fix the original task message: - taskMessage, err := s.ChannelMessageSendComplex(th.ID, &discordgo.MessageSend{ - Content: content, - Components: []discordgo.MessageComponent{ - discordgo.ActionsRow{ - Components: []discordgo.MessageComponent{ - discordgo.Button{ - Label: "Start", - Style: discordgo.SuccessButton, - Disabled: false, - CustomID: "task_start", - }, - discordgo.Button{ - Label: "Close", - Style: discordgo.DangerButton, - Disabled: true, - CustomID: "task_close", - }, - }, - }, - }, - }) - if err != nil { - log.Printf("th start message edition is not complete: %v", err) - } - - err = c.controller.UpdateTasksMessageID(context.TODO(), taskMessage.ID, t.ID) - if err != nil { - s.ChannelMessageSend(th.ID, fmt.Sprintf("unable to update task at the db, %v", err)) - return - } -} - -func (c *client) HandleTaskButtons() Component { - return Component{ - Handler: c.handleTaskButton, - } -} - -func (c *client) handleTaskButton(s *discordgo.Session, i *discordgo.InteractionCreate) { - - // Send an empty interaction response; ---------------------------------------------------------------- - s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseUpdateMessage, - Data: &discordgo.InteractionResponseData{}, - }) - - // Get assignee value; --------------------------------------------------------------------------------- - user := i.Member.User.Mention() - - var ( - opt int = -1 - doneButtonIsDisabled bool = false - state domain.TaskState = domain.NewTaskState() - message string - ) - - // Check what flow was touched: ------------------------------------------------------------------------- - switch i.Interaction.MessageComponentData().CustomID { - case "task_start": - opt = 0 - doneButtonIsDisabled = false - state = domain.InrpogressTaskState() - message = "взята в работу" - case "task_close": - opt = 1 - doneButtonIsDisabled = true - state = domain.DoneTaskState() - message = "выполнена" - } - - // Send the task update to db -------------------------------------------------------------------------- - convertable, err := c.controller.UpdateTask(i.Message.ID, opt, user) - if err != nil { - s.ChannelMessageSend(i.ChannelID, fmt.Sprintf("Unable to update task at the db w/ error: %v", err)) - return - } - - // Map DB's response to domain.Task: ------------------------------------------------------------------- - task := convertable. - ExtractDomain() - - newContent := task.DiscordMessage(state) - - // Send message to the creator in Telegram: ------------------------------------------------------------- - - if task.CreatorLink != "" { - c.sendTelegramMessageToCreator( - task.CreatorLink, - fmt.Sprintf("Task ID: %d %s", task.ID, message)) - } - - // Send a message to the thread about the task was started: --------------------------------------------- - _, err = s.ChannelMessageSendComplex(i.ChannelID, &discordgo.MessageSend{ - Content: newContent, - }) - if err != nil { - log.Printf("error while sending start task message: %v", err) - } - - // Fix the original task message: ---------------------------------------------------------------------- - _, err = s.ChannelMessageEditComplex(&discordgo.MessageEdit{ - Channel: i.ChannelID, - ID: i.Message.ID, - Components: []discordgo.MessageComponent{ - discordgo.ActionsRow{ - Components: []discordgo.MessageComponent{ - discordgo.Button{ - Label: "Close", - Style: discordgo.DangerButton, - Disabled: doneButtonIsDisabled, - CustomID: "task_close", - }, - }, - }, - }, - }) - if err != nil { - log.Printf("th start message edition is not complete: %v", err) - } - - // Устанавливаем тэги статуса на тред --------------------------------------------------------------------- - err = c.setFlag(s, i, &c.Tags[opt]) - if err != nil { - log.Printf("error while `start` tag setting: %v", err) - } -} - -type TelegramMessage struct { - ChatID string `json:"chat_id"` - Text string `json:"text"` - DisableNotification bool `json:"disable_notification"` - ParseMode string `json:"parse_mode"` - DisablePreview bool `json:"disable_web_page_preview"` -} - -func (c *client) sendTelegramMessageToCreator(tgChatID string, text string) { - - http := req.C() - http.R(). - SetBody(&TelegramMessage{ - ChatID: tgChatID, - Text: text, - DisableNotification: true, - ParseMode: "HTML", - DisablePreview: true, - }). - Post("https://api.telegram.org/bot" + c.tgConf.Token + "/sendMessage") - // [HTTP Kit Marlerino]::POST( - // "https://api.telegram.org/bot" + Config.botToken + - // "/sendMessage", - // "", - // Object( - // "chat_id", - // thisRow.[Creator ID].ToText(), - // "text", - // Format( - // "{2} взята в работу", - // thisRow.ObjectLink().ToText(), - // "Задача" - // ), - // "disable_notification", - // true, - // "parse_mode", - // "HTML", - // "disable_web_page_preview", - // true - // ) - // ) - -} diff --git a/client/discord/handler/handle_folder.go b/client/discord/handler/handle_folder.go deleted file mode 100644 index ab7a6e7..0000000 --- a/client/discord/handler/handle_folder.go +++ /dev/null @@ -1,97 +0,0 @@ -package handler - -import ( - "context" - "log" - "ticket-pimp/internal/controller" - - "github.com/bwmarrin/discordgo" -) - -func (c *client) CreateFolderHandler(nameMinLenght int) Command { - const ( - nameOption string = "folder_name" - ) - return Command{ - - Command: discordgo.ApplicationCommand{ - Name: "folder", - Description: "Command for cloud folder creation", - Options: []*discordgo.ApplicationCommandOption{ - { - Type: discordgo.ApplicationCommandOptionString, - Name: nameOption, - Description: "Type the folder's name", - Required: false, - MinLength: &nameMinLenght, - }, - }, - }, - - Handler: c.createFolderHandler, - } -} - -func (c *client) createFolderHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { - const ( - nameOption string = "folder_name" - ) - - // Моментальный ответ для избежания столкновения с протуханием токена - initialResponse := discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Flags: discordgo.MessageFlagsEphemeral, - Content: "👩‍🍳 Cooking your query..", - }, - } - - s.InteractionRespond(i.Interaction, &initialResponse) - - // Определение переменной для ответа - var result string = "unexpected result" - - // Определение выбранных вариантов ответа - options := i.ApplicationCommandData().Options - - optionMap := make(map[string]*discordgo.ApplicationCommandInteractionDataOption, len(options)) - for _, opt := range options { - optionMap[opt.Name] = opt - } - - // Creating request: - var req controller.FolderRequest - name, insertedValueNotNil := optionMap[nameOption] - dchan, err := s.Channel(i.ChannelID) - - if err != nil { - log.Printf("error while identifying channel: %v", err) - } else { - - if dchan.ParentID == c.conf.IsProjectChannel { - req.ChannelID = dchan.ID - if insertedValueNotNil { - req.InsertedName = name.StringValue() - } - - } else { - req.ChannelID = "" - if insertedValueNotNil { - req.InsertedName = name.StringValue() - } - } - } - - // Making request: - resp := c.controller.CreateFolder(context.TODO(), req) - if resp.Project == nil { - result = "Надо написать имя для папки, или создать папку из проекта!" - } else { - result = resp.Project.DiscordString() - if resp.Message != nil { - result += "Errors: " + resp.Message.Error() - } - } - - c.defaultFollowUp(result, s, i) -} diff --git a/client/discord/handler/handle_git.go b/client/discord/handler/handle_git.go deleted file mode 100644 index f41ff60..0000000 --- a/client/discord/handler/handle_git.go +++ /dev/null @@ -1,127 +0,0 @@ -package handler - -import ( - "context" - "log" - "ticket-pimp/internal/controller" - - "github.com/bwmarrin/discordgo" -) - -func (c *client) CreateRepoHandler(repoNameMinLength int) Command { - const ( - repoType = "repo_type" - projectRepo = "project_repo" - buildRepo = "build_repo" - nameOption = "repo_name" - ) - - return Command{ - Command: discordgo.ApplicationCommand{ - Name: "repo", - Description: "Creates repository of selected type. Name used for projects channels only", - Options: []*discordgo.ApplicationCommandOption{ - { - Type: discordgo.ApplicationCommandOptionString, - Name: repoType, - Description: "The type of repo", - Required: true, - Choices: []*discordgo.ApplicationCommandOptionChoice{ - { - Name: "Unity project repo", - Value: projectRepo, - }, - { - Name: "XCode build repo", - Value: buildRepo, - }, - }, - }, - { - Type: discordgo.ApplicationCommandOptionString, - Name: nameOption, - Description: "Type the repository's name", - Required: false, - MinLength: &repoNameMinLength, - }, - }, - }, - Handler: c.createRepoHandler, - } -} - -func (c *client) createRepoHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { - const ( - repoType = "repo_type" - projectRepo = "project_repo" - buildRepo = "build_repo" - nameOption = "repo_name" - ) - // Моментальный ответ для избежания столкновения с протуханием токена - initialResponse := discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Flags: discordgo.MessageFlagsEphemeral, - Content: "👩‍🍳 Cooking your query..", - }, - } - - s.InteractionRespond(i.Interaction, &initialResponse) - - // Определение переменной для ответа - var result string = "unexpected result" - - // Access options in the order provided by the user. - options := i.ApplicationCommandData().Options - - // Or convert the slice into a map - optionMap := make(map[string]*discordgo.ApplicationCommandInteractionDataOption, len(options)) - for _, opt := range options { - optionMap[opt.Name] = opt - } - - // Creating request: - var req controller.GitRequest - name, insertedValueNotNil := optionMap[nameOption] - isBuild := optionMap[repoType] - switch isBuild.StringValue() { - case buildRepo: - req.IsBuildGit = true - case projectRepo: - req.IsBuildGit = false - } - dchan, err := s.Channel(i.ChannelID) - - if err != nil { - log.Printf("error while identifying channel: %v", err) - } else { - - if dchan.ParentID == c.conf.IsProjectChannel { - req.ChannelID = dchan.ID - if insertedValueNotNil { - req.InsertedName = name.StringValue() - } - } else { - req.ChannelID = "" - if insertedValueNotNil { - req.InsertedName = name.StringValue() - } - } - } - - // Making request: - resp := c.controller.CreateGit(context.TODO(), req) - if resp.Project == nil { - if resp.Message != nil { - result = resp.Message.Error() - } - } else { - - result = resp.Project.DiscordString() - if resp.Message != nil { - result += "Errors: " + resp.Message.Error() - } - - } - c.defaultFollowUp(result, s, i) -} diff --git a/client/discord/handler/handle_ping.go b/client/discord/handler/handle_ping.go deleted file mode 100644 index 4f91795..0000000 --- a/client/discord/handler/handle_ping.go +++ /dev/null @@ -1,30 +0,0 @@ -package handler - -import ( - "log" - - "github.com/bwmarrin/discordgo" -) - -func (c *client) Ping() Command { - return Command{ - Command: discordgo.ApplicationCommand{ - Name: "ping", - Description: "pongs in a reply", - }, - Handler: c.ping, - } -} - -func (c *client) ping(s *discordgo.Session, i *discordgo.InteractionCreate) { - - err := s.InteractionRespond(i.Interaction, &discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Content: "Pong to: " + i.Member.User.Mention(), - }, - }) - if err != nil { - log.Println(err) - } -} diff --git a/client/discord/handler/handle_ticket.go b/client/discord/handler/handle_ticket.go deleted file mode 100644 index f904e5d..0000000 --- a/client/discord/handler/handle_ticket.go +++ /dev/null @@ -1,220 +0,0 @@ -package handler - -import ( - "context" - "fmt" - "log" - "ticket-pimp/internal/domain" - - "github.com/bwmarrin/discordgo" -) - -func (c *client) GetInfo() Command { - return Command{ - Command: discordgo.ApplicationCommand{ - Name: "info", - Description: "Get project's info", - }, - Handler: c.getInfo, - } -} - -func (c *client) getInfo(s *discordgo.Session, i *discordgo.InteractionCreate) { - - // Моментальный ответ для избежания столкновения с протуханием токена - initialResponse := discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Flags: discordgo.MessageFlagsEphemeral, - Content: "👩‍🍳 Cooking your query..", - }, - } - - s.InteractionRespond(i.Interaction, &initialResponse) - - var result string - - // Get channel from the request - dchan, err := s.Channel(i.ChannelID) - if err != nil { - result = "unable to get channel from the message" - } else { - project, err := c.controller.GetProjectByChannelID(context.TODO(), dchan.ID) - if err != nil { - result = err.Error() - } else { - if project != nil { - result = project.DiscordString() - if err != nil { - result += "Errors: " + err.Error() - } - } else { - result = "Something wrong with retrieving project from db" - } - - } - } - - c.defaultFollowUp(result, s, i) -} - -func (c *client) InitProjectFromChannel(minLength int) Command { - return Command{ - Command: discordgo.ApplicationCommand{ - Name: "init_project", - Description: "Connect project with Coda ID", - Options: []*discordgo.ApplicationCommandOption{ - { - Type: discordgo.ApplicationCommandOptionString, - Name: "key", - Description: "Project's key from Coda.io", - Required: true, - MinLength: &minLength, - }, - }, - }, - Handler: c.initProjectFromChannel, - } -} - -func (c *client) initProjectFromChannel(s *discordgo.Session, i *discordgo.InteractionCreate) { - - // Моментальный ответ для избежания столкновения с протуханием токена - initialResponse := discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Flags: discordgo.MessageFlagsEphemeral, - Content: "👩‍🍳 Cooking your query..", - }, - } - - s.InteractionRespond(i.Interaction, &initialResponse) - - var result string - - // Get channel from the request - dchan, err := s.Channel(i.ChannelID) - if err != nil { - result = "unable to get channel from the message" - } else { - if dchan.ParentID != c.conf.IsProjectChannel { - // Sending result: - _, err := s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{ - Content: "This channel is not at the project's group", - }) - - if err != nil { - s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{ - Content: fmt.Sprintf("Something went wrong: %v", err), - }) - return - } - return - } - - // Access options in the order provided by the user. - options := i.ApplicationCommandData().Options - - // Or convert the slice into a map - optionMap := make(map[string]*discordgo.ApplicationCommandInteractionDataOption, len(options)) - for _, opt := range options { - optionMap[opt.Name] = opt - } - - if option, ok := optionMap["key"]; ok { - var errMsg error = nil - - project, err := c.controller.InitProjectInChannel(context.TODO(), i.ChannelID, option.StringValue()) - if err != nil { - result = fmt.Sprintf("unable to init project: %v", err) - } else { - result = project.DiscordString() - if errMsg != nil { - result += "Errors: " + errMsg.Error() - } - } - } - } - - c.defaultFollowUp(result, s, i) -} - -func (c *client) CreateTicketHandler(repoNameMinLength int) Command { - return Command{ - Command: discordgo.ApplicationCommand{ - Name: "project", - Description: "Create new development ticket", - Options: []*discordgo.ApplicationCommandOption{ - { - Type: discordgo.ApplicationCommandOptionString, - Name: "project_name", - Description: "Temporary project name", - Required: true, - MinLength: &repoNameMinLength, - }, - }, - }, - Handler: c.createTicketHandler, - } -} - -func (c *client) createTicketHandler(s *discordgo.Session, i *discordgo.InteractionCreate) { - - // Моментальный ответ для избежания столкновения с протуханием токена - initialResponse := discordgo.InteractionResponse{ - Type: discordgo.InteractionResponseChannelMessageWithSource, - Data: &discordgo.InteractionResponseData{ - Flags: discordgo.MessageFlagsEphemeral, - Content: "👩‍🍳 Cooking your query..", - }, - } - - s.InteractionRespond(i.Interaction, &initialResponse) - - var result string - // Access options in the order provided by the user. - options := i.ApplicationCommandData().Options - - // Or convert the slice into a map - optionMap := make(map[string]*discordgo.ApplicationCommandInteractionDataOption, len(options)) - for _, opt := range options { - optionMap[opt.Name] = opt - } - - if option, ok := optionMap["project_name"]; ok { - dchan, err := s.GuildChannelCreate(i.GuildID, option.StringValue(), discordgo.ChannelTypeGuildText) - if err != nil { - result = fmt.Sprintf("chan creation problem: %v\n", err) - } else { - p, err := c.controller.ProjectCreate(context.TODO(), domain.Project{ - ChannelID: dchan.ID, - }) - if err != nil { - result = fmt.Sprintf("unable to create project: %v\n", err) - _, err := s.ChannelDelete(dchan.ID) - if err != nil { - result += fmt.Sprintf("\nunable to clean channel: %v\n", err) - } - } else { - edit := discordgo.ChannelEdit{ - Name: p.ShortName, - ParentID: "1150719794853716028", - } - - dchan, err = s.ChannelEdit(dchan.ID, &edit) - if err != nil { - result = fmt.Sprintf("channel %s created, but unable to edit follow up message: %v\n", p.ShortName, err) - - } else { - _, err = s.ChannelMessageSend(dchan.ID, "Hello!") - if err != nil { - log.Printf("message send problem: %v\n", err) - } - result = "Project " + p.ShortName + "Was created" - } - } - } - } - - c.defaultFollowUp(result, s, i) -} diff --git a/client/discord/handler/handler.go b/client/discord/handler/handler.go deleted file mode 100644 index cb64fc3..0000000 --- a/client/discord/handler/handler.go +++ /dev/null @@ -1,85 +0,0 @@ -package handler - -import ( - "fmt" - "ticket-pimp/internal/controller" - "ticket-pimp/internal/domain" - - "github.com/bwmarrin/discordgo" -) - -type client struct { - Commands []Command - Components []Component - - Tags []discordgo.ForumTag - - controller controller.WorkflowController - conf *domain.DiscordConfig - tgConf *domain.TelegramConfig -} - -// Подключение роутов к Discord боту -func InitRouter(wc controller.WorkflowController, conf *domain.DiscordConfig, tgConf *domain.TelegramConfig) *client { - - var r client - r.controller = wc - r.conf = conf - - r.Commands = append(r.Commands, - r.CreateRepoHandler(3), - r.CreateFolderHandler(3), - r.Ping(), - r.CreateTicketHandler(3), - r.InitProjectFromChannel(3), - r.GetInfo(), - ) - r.Components = append(r.Components, - r.HandleTaskButtons(), - ) - - r.Tags = append( - r.Tags, - discordgo.ForumTag{ - Name: "В работе", - Moderated: true, - EmojiName: "👩‍🍳", - }, - - discordgo.ForumTag{ - Name: "Готово", - Moderated: true, - EmojiName: "✅", - }) - r.tgConf = tgConf - - return &r -} - -// -// Подключение роутов к Discord боту - -type Command struct { - Command discordgo.ApplicationCommand - Handler func(s *discordgo.Session, i *discordgo.InteractionCreate) -} - -type Component struct { - Component discordgo.MessageComponent - Handler func(s *discordgo.Session, i *discordgo.InteractionCreate) -} - -func (h *client) defaultFollowUp(answer string, s *discordgo.Session, i *discordgo.InteractionCreate) { - - // Sending result: - _, err := s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{ - Content: answer, - }) - - if err != nil { - s.FollowupMessageCreate(i.Interaction, true, &discordgo.WebhookParams{ - Content: fmt.Sprintf("Something went wrong: %v", err), - }) - return - } -} diff --git a/client/telegram/telegram.go b/client/telegram/telegram.go index 63fb7d8..5e71200 100644 --- a/client/telegram/telegram.go +++ b/client/telegram/telegram.go @@ -3,19 +3,19 @@ package telegram import ( "context" "log" - "ticket-pimp/client/telegram/handler" + "ticket-pimp/client/telegram/telegram_handler" "ticket-pimp/internal/controller" "ticket-pimp/internal/domain" - "ticket-pimp/internal/services" + "ticket-pimp/internal/external" "github.com/mr-linch/go-tg" "github.com/mr-linch/go-tg/tgb" ) type TelegramOptions struct { - GitService *services.Git - CloudService *services.Cloud - Coda *services.Coda + GitService *external.Git + CloudService *external.Cloud + Coda *external.Coda AppConfig *domain.Config Controller *controller.WorkflowController } @@ -30,7 +30,7 @@ func Run(ctx context.Context, opts TelegramOptions) error { log.Print("Start telegram bot init..") client := tg.New(opts.AppConfig.Telegram.Token) - h := handler.NewHandler( + h := telegram_handler.NewHandler( opts.GitService, opts.CloudService, opts.Coda, diff --git a/client/telegram/handler/handle_application.go b/client/telegram/telegram_handler/handle_application.go similarity index 97% rename from client/telegram/handler/handle_application.go rename to client/telegram/telegram_handler/handle_application.go index a24f673..38cba20 100644 --- a/client/telegram/handler/handle_application.go +++ b/client/telegram/telegram_handler/handle_application.go @@ -1,4 +1,4 @@ -package handler +package telegram_handler // func (h *Handler) DevelopmentTaskHandler(ctx context.Context, mu *tgb.MessageUpdate) error { diff --git a/client/telegram/handler/handle_farmtask.go b/client/telegram/telegram_handler/handle_farmtask.go similarity index 98% rename from client/telegram/handler/handle_farmtask.go rename to client/telegram/telegram_handler/handle_farmtask.go index 128c120..6442dc6 100644 --- a/client/telegram/handler/handle_farmtask.go +++ b/client/telegram/telegram_handler/handle_farmtask.go @@ -1,4 +1,4 @@ -package handler +package telegram_handler import ( "context" diff --git a/client/telegram/handler/handle_folder.go b/client/telegram/telegram_handler/handle_folder.go similarity index 97% rename from client/telegram/handler/handle_folder.go rename to client/telegram/telegram_handler/handle_folder.go index fa88377..0a903f9 100644 --- a/client/telegram/handler/handle_folder.go +++ b/client/telegram/telegram_handler/handle_folder.go @@ -1,4 +1,4 @@ -package handler +package telegram_handler import ( "context" diff --git a/client/telegram/handler/handle_git.go b/client/telegram/telegram_handler/handle_git.go similarity index 97% rename from client/telegram/handler/handle_git.go rename to client/telegram/telegram_handler/handle_git.go index 52e0a76..d53240d 100644 --- a/client/telegram/handler/handle_git.go +++ b/client/telegram/telegram_handler/handle_git.go @@ -1,4 +1,4 @@ -package handler +package telegram_handler import ( "context" diff --git a/client/telegram/handler/handle_init.go b/client/telegram/telegram_handler/handle_init.go similarity index 98% rename from client/telegram/handler/handle_init.go rename to client/telegram/telegram_handler/handle_init.go index c01ad4c..266e7d2 100644 --- a/client/telegram/handler/handle_init.go +++ b/client/telegram/telegram_handler/handle_init.go @@ -1,4 +1,4 @@ -package handler +package telegram_handler import ( "context" diff --git a/client/telegram/handler/handler.go b/client/telegram/telegram_handler/handler.go similarity index 63% rename from client/telegram/handler/handler.go rename to client/telegram/telegram_handler/handler.go index da3a326..1b91fa4 100644 --- a/client/telegram/handler/handler.go +++ b/client/telegram/telegram_handler/handler.go @@ -1,23 +1,23 @@ -package handler +package telegram_handler import ( + "ticket-pimp/adapters" "ticket-pimp/internal/controller" - "ticket-pimp/internal/services" ) type Handler struct { - git services.IGit - cloud services.ICloud - coda services.ICoda + git adapters.IGit + cloud adapters.ICloud + coda adapters.ICoda key string id string controller *controller.WorkflowController } func NewHandler( - git services.IGit, - cloud services.ICloud, - coda services.ICoda, + git adapters.IGit, + cloud adapters.ICloud, + coda adapters.ICoda, controller *controller.WorkflowController, ) *Handler { diff --git a/cmd/main.go b/cmd/main.go index f137cf0..6cc7258 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -2,6 +2,7 @@ package main import ( "context" + "flag" "fmt" "log" "os" @@ -10,16 +11,19 @@ import ( "ticket-pimp/internal/controller" "ticket-pimp/internal/domain" - "ticket-pimp/internal/services" + "ticket-pimp/internal/external" "ticket-pimp/client/discord" "ticket-pimp/client/telegram" "github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/stdlib" + "github.com/pkg/errors" migrate "github.com/rubenv/sql-migrate" + "golang.org/x/sync/errgroup" ) +<<<<<<< HEAD const ( envfile = "prod.env" migrationfile = "../internal/storage/migrate" @@ -27,31 +31,45 @@ const ( // envfile = "../docker/prod.env" // migrationfile = "../internal/storage/migrate" ) +======= +const migrationfile = "../internal/storage/migrate" +>>>>>>> dev func main() { log.Print("started") - config := domain.InitConfig(envfile) - run(config) + + env := flag.Int("env", -1, "0 for development env file, 1 for production environment run, missing flag for build") + flag.Parse() + var envPath string + + switch *env { + case 0: + envPath = ".env" + case 1: + envPath = "../docker/prod.env" + default: + envPath = "prod.env" + } + + config := domain.InitConfig(envPath) + run(&config) } -func run(conf domain.Config) { - ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill, syscall.SIGTERM) - defer cancel() +func Go(ctx context.Context, fns ...func(context.Context) error) error { + group, ctx := errgroup.WithContext(ctx) - // -- DB connection init -- START - connString := fmt.Sprintf( - "postgresql://%s:%s@%s:%s/%s", - conf.DB.User, conf.DB.Pass, conf.DB.Host, conf.DB.Port, conf.DB.Name, - ) - conn, err := pgxpool.New( - ctx, - connString) - if err != nil { - log.Fatalf("DB connection failed: %v", err) + for _, fn := range fns { + fn := fn + group.Go(func() error { + return fn(ctx) + }) } - // -- DB connection init -- END - // Aply migrations: + return group.Wait() +} + +func applyMigrations(connString string) { + // Apply migrations: dbConnConfig, err := pgxpool.ParseConfig(connString) if err != nil { @@ -71,42 +89,69 @@ func run(conf domain.Config) { } fmt.Printf("Applied %d migrations!\n", n) - db.Close() - - // - - gitService := services.NewGit(conf.Git) - cloudService := services.NewCloud(conf.Cloud) - codaService := services.NewCodaClient(conf.Coda) - - // Инициализация контроллера: - controller := controller.NewWorkflowController( - gitService, - cloudService, - codaService, - conn, - ) - - go func() { - opts := discord.DiscordOptions{ - Controller: controller, - Config: &conf, - } - if err := discord.Run(conf, opts); err != nil { - log.Fatalf("discord bot cannot be runned: %v", err) - } - }() - - opts := telegram.TelegramOptions{ - GitService: gitService, - CloudService: cloudService, - Coda: codaService, - AppConfig: &conf, - Controller: controller, - } - - if err := telegram.Run(ctx, opts); err != nil { - log.Fatalf("telegram bot cannot be runned: %v", err) - defer os.Exit(1) + err = db.Close() + if err != nil { + log.Fatal("unable to close db connection") } } + +func run(c *domain.Config) { + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, os.Kill, syscall.SIGTERM) + defer cancel() + + // -- DB connection init -- START + connString := fmt.Sprintf( + "postgresql://%s:%s@%s:%s/%s", + c.DB.User, c.DB.Pass, c.DB.Host, c.DB.Port, c.DB.Name, + ) + conn, err := pgxpool.New( + ctx, + connString) + if err != nil { + log.Fatalf("DB connection failed: %v", err) + } + + // Apply migrations: + applyMigrations(connString) + + // Init services instances: + git := external.NewGit(c.Git) + cloud := external.NewCloud(c.Cloud) + coda := external.NewCoda(c.Coda) + + // Controller instance init: + controller := controller.NewApp( + git, + cloud, + coda, + conn, + c, + ) + + Go(ctx, + func(ctx context.Context) error { + opts := discord.DiscordOptions{ + Controller: controller, + Config: c, + } + if err := discord.Run(c, opts); err != nil { + return errors.Errorf("discord bot cannot be runned: %v", err) + } + return nil + }, + func(ctx context.Context) error { + opts := telegram.TelegramOptions{ + GitService: git, + CloudService: cloud, + Coda: coda, + AppConfig: c, + Controller: controller, + } + + if err := telegram.Run(ctx, opts); err != nil { + return errors.Errorf("telegram bot cannot be runned: %v", err) + } + return nil + }, + ) +} diff --git a/go.mod b/go.mod index c4183a4..cf36b2c 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,9 @@ require ( github.com/jackc/pgx/v5 v5.4.3 github.com/joho/godotenv v1.5.1 github.com/mr-linch/go-tg v0.9.1 + github.com/pkg/errors v0.9.1 + github.com/rubenv/sql-migrate v1.5.2 + golang.org/x/sync v0.2.0 ) require ( @@ -20,22 +23,18 @@ require ( github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect - github.com/jackc/pgx v3.6.2+incompatible // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect github.com/onsi/ginkgo/v2 v2.10.0 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/quic-go/qpack v0.4.0 // indirect github.com/quic-go/qtls-go1-19 v0.3.2 // indirect github.com/quic-go/qtls-go1-20 v0.2.2 // indirect github.com/quic-go/quic-go v0.35.1 // indirect - github.com/rubenv/sql-migrate v1.5.2 // indirect github.com/stretchr/testify v1.8.4 // indirect github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce // indirect golang.org/x/crypto v0.10.0 // indirect golang.org/x/exp v0.0.0-20230522175609-2e198f4a06a1 // indirect golang.org/x/mod v0.10.0 // indirect golang.org/x/net v0.11.0 // indirect - golang.org/x/sync v0.2.0 // indirect golang.org/x/sys v0.9.0 // indirect golang.org/x/text v0.10.0 // indirect golang.org/x/tools v0.9.3 // indirect diff --git a/go.sum b/go.sum index 683817c..83a0ff1 100644 --- a/go.sum +++ b/go.sum @@ -6,8 +6,12 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs= github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw= github.com/go-logr/logr v1.2.4 h1:g01GSCwiDw2xSZfjJ2/T9M+S6pFdcNtFYsp+Y43HYDQ= +github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= +github.com/gobuffalo/logger v1.0.6 h1:nnZNpxYo0zx+Aj9RfMPBm+x9zAU2OayFh/xrAWi34HU= +github.com/gobuffalo/packd v1.0.1 h1:U2wXfRr4E9DH8IdsDLlRFwTZTK7hLfq9qT/QHXGVe/0= +github.com/gobuffalo/packr/v2 v2.8.3 h1:xE1yzvnO56cUC0sTpKR3DIbxZgB54AftTFMhB2XEWlY= github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= @@ -29,14 +33,20 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a h1:bbPeKD0xmW/Y25WS6cokEszi5g+S0QxI/d45PkRi7Nk= github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= -github.com/jackc/pgx v3.6.2+incompatible h1:2zP5OD7kiyR3xzRYMhOcXVvkDZsImVXfj+yIyTQf3/o= -github.com/jackc/pgx v3.6.2+incompatible/go.mod h1:0ZGrqGqkRlliWnWB4zKnWtjbSWbGkVEFm4TeybAXq+I= github.com/jackc/pgx/v5 v5.4.3 h1:cxFyXhxlvAifxnkKKdlxv8XqUf59tDlYjnV5YYfsJJY= github.com/jackc/pgx/v5 v5.4.3/go.mod h1:Ig06C2Vu0t5qXC60W8sqIthScaEnFvojjj9dSljmHRA= github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/karrick/godirwalk v1.16.1 h1:DynhcF+bztK8gooS0+NDJFrdNZjJ3gzVzC545UNA9iw= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/lib/pq v1.10.7 h1:p7ZhMD+KsSRozJr34udlUrhboJwWAgCg34+/ZZNvZZw= +github.com/markbates/errx v1.1.0 h1:QDFeR+UP95dO12JgW+tgi2UVfo0V8YBHiUIOaeBPiEI= +github.com/markbates/oncer v1.0.0 h1:E83IaVAHygyndzPimgUYJjbshhDTALZyXxvk9FOlQRY= +github.com/markbates/safe v1.0.1 h1:yjZkbvRM6IzKj9tlu/zMJLS0n/V351OZWRnF3QfaUxI= +github.com/mattn/go-sqlite3 v1.14.15 h1:vfoHhTN1af61xCRSWzFIWzx2YskyMTwHLrExkBOjvxI= github.com/mr-linch/go-tg v0.9.1 h1:4KNe7zwFG6svgM9w6pIcH3R7QWa6hIK8tCisQiFRCpU= github.com/mr-linch/go-tg v0.9.1/go.mod h1:276w69YW4pEo3ZYta+LQe4v/ut2w2h1ksP4ziBWkK98= github.com/onsi/ginkgo/v2 v2.10.0 h1:sfUl4qgLdvkChZrWCYndY2EAu9BRIw1YphNAzy1VNWs= @@ -46,6 +56,7 @@ github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.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/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= github.com/quic-go/qpack v0.4.0 h1:Cr9BXA1sQS2SmDUWjSofMPNKmvF6IiIfDRmgU0w1ZCo= github.com/quic-go/qpack v0.4.0/go.mod h1:UZVnYIfi5GRk+zI9UMaCPsmZ2xKJP7XBUvVyT1Knj9A= github.com/quic-go/qtls-go1-19 v0.3.2 h1:tFxjCFcTQzK+oMxG6Zcvp4Dq8dx4yD3dDiIiyc86Z5U= @@ -54,11 +65,12 @@ github.com/quic-go/qtls-go1-20 v0.2.2 h1:WLOPx6OY/hxtTxKV1Zrq20FtXtDEkeY00CGQm8G github.com/quic-go/qtls-go1-20 v0.2.2/go.mod h1:JKtK6mjbAVcUTN/9jZpvLbGxvdWIKS8uT7EiStoU1SM= github.com/quic-go/quic-go v0.35.1 h1:b0kzj6b/cQAf05cT0CkQubHM31wiA+xH3IBkxP62poo= github.com/quic-go/quic-go v0.35.1/go.mod h1:+4CVgVppm0FNjpG3UcX8Joi/frKOH7/ciD5yGcwOO1g= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rubenv/sql-migrate v1.5.2 h1:bMDqOnrJVV/6JQgQ/MxOpU+AdO8uzYYA/TxFUBzFtS0= github.com/rubenv/sql-migrate v1.5.2/go.mod h1:H38GW8Vqf8F0Su5XignRyaRcbXbJunSWxs+kmzlg0Is= +github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= -github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -95,6 +107,7 @@ golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.9.0 h1:KS/R3tvhPqvJvwcKfnBHJwwthS11LRhmM5D59eEXa0s= golang.org/x/sys v0.9.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.9.0 h1:GRRCnKYhdQrD8kfRAdQ6Zcw1P0OcELxGLKJvtjVMZ28= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.10.0 h1:UpjohKhiEgNc0CSauXmwYftY1+LlaC75SJwh0SgCX58= @@ -112,6 +125,6 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.30.0 h1:kPPoIgf3TsEvrm0PFe15JQ+570QVxYzEvvHqChK+cng= google.golang.org/protobuf v1.30.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/controller/control_coda.go b/internal/controller/control_coda.go new file mode 100644 index 0000000..b0798ed --- /dev/null +++ b/internal/controller/control_coda.go @@ -0,0 +1,27 @@ +package controller + +import ( + "context" + "fmt" + "ticket-pimp/internal/domain" +) + +func (wc *WorkflowController) CreateCoda(guildID string, chanID string) (string, error) { + + p, err := wc.GetProjectByChannelID(context.TODO(), chanID) + if err != nil { + return "", err + } + + requestResult, err := wc.ICoda.CreateApp(domain.CodaApplication{ + ID: p.Key, + Summary: p.Name, + URL: fmt.Sprintf("https://discord.com/channels/%s/%s", guildID, chanID), + Git: p.ProjectGit, + GitBuild: p.BuildGit, + Folder: p.Folder, + }) + + return requestResult, err + +} diff --git a/internal/controller/control_folder.go b/internal/controller/control_folder.go index 7a8f61b..9ca52e1 100644 --- a/internal/controller/control_folder.go +++ b/internal/controller/control_folder.go @@ -35,13 +35,13 @@ func (wc *WorkflowController) CreateFolder(ctx context.Context, req FolderReques if project != nil { switch { - case project.Cloud != "": + case project.Folder != "": return &ProjectResponse{ Project: project, Message: nil, } - case project.ShortName != "": - name = project.ShortName + case project.Key != "": + name = project.Key case req.InsertedName != "": name = req.InsertedName } @@ -65,11 +65,12 @@ func (wc *WorkflowController) CreateFolder(ctx context.Context, req FolderReques result = ProjectResponse{ Project: &domain.Project{ ID: string(dbticket.ID), - ShortName: dbticket.Key.String, + Name: dbticket.Title.String, + Key: dbticket.Key.String, ChannelID: dbticket.Channelid.String, ProjectGit: dbticket.ProjectGit.String, BuildGit: dbticket.BuildGit.String, - Cloud: dbticket.Folder.String, + Folder: dbticket.Folder.String, }, Message: response.ErrMessage, } @@ -78,7 +79,7 @@ func (wc *WorkflowController) CreateFolder(ctx context.Context, req FolderReques response := wc.ICloud.CreateFolder(req.InsertedName) result = ProjectResponse{ Project: &domain.Project{ - Cloud: response.Folder.PrivateURL, + Folder: response.Folder.PrivateURL, }, Message: response.ErrMessage, } diff --git a/internal/controller/control_git.go b/internal/controller/control_git.go index 44a38d9..6d27967 100644 --- a/internal/controller/control_git.go +++ b/internal/controller/control_git.go @@ -24,8 +24,8 @@ func (wc *WorkflowController) createGitForExistingProject(ctx context.Context, r dbticket db.Ticket ) switch { - case p.ShortName != "": - name = p.ShortName + case p.Key != "": + name = p.Key if req.IsBuildGit { name += "-build" } @@ -81,11 +81,12 @@ func (wc *WorkflowController) createGitForExistingProject(ctx context.Context, r return &ProjectResponse{ Project: &domain.Project{ ID: string(dbticket.ID), - ShortName: dbticket.Key.String, + Name: dbticket.Title.String, + Key: dbticket.Key.String, ChannelID: dbticket.Channelid.String, ProjectGit: dbticket.ProjectGit.String, BuildGit: dbticket.BuildGit.String, - Cloud: dbticket.Folder.String, + Folder: dbticket.Folder.String, }, Message: err, } diff --git a/internal/controller/control_project.go b/internal/controller/control_project.go index d04cbb3..0242846 100644 --- a/internal/controller/control_project.go +++ b/internal/controller/control_project.go @@ -3,6 +3,7 @@ package controller import ( "context" "fmt" + "strconv" "ticket-pimp/internal/domain" "ticket-pimp/internal/storage/db" @@ -41,17 +42,19 @@ func (wc *WorkflowController) ProjectCreate(ctx context.Context, project domain. return nil, err } - project.ShortName = fmt.Sprintf( + project.Key = fmt.Sprintf( "%s-%d", appconfig.TicketKey.String, appconfig.TicketID.Int32, ) + // Set ID from the DB raw: project.ID = string(appconfig.TicketID.Int32) projectRow, err := qtx.CreateTicket(ctx, db.CreateTicketParams{ - Key: pgtype.Text{String: project.ShortName, Valid: true}, + Key: pgtype.Text{String: project.Key, Valid: true}, Channelid: pgtype.Text{String: project.ChannelID, Valid: true}, + Title: pgtype.Text{String: project.Name, Valid: true}, }) if err != nil { tx.Rollback(ctx) @@ -65,25 +68,24 @@ func (wc *WorkflowController) ProjectCreate(ctx context.Context, project domain. } func (wc *WorkflowController) GetProjectByChannelID(ctx context.Context, id string) (*domain.Project, error) { - var proj domain.Project + dbTicket, err := wc.q.GetTicketByChannelID(ctx, pgtype.Text{String: id, Valid: true}) if err != nil { if err == pgx.ErrNoRows { return nil, nil } return nil, err - } else { - proj = domain.Project{ - ID: string(dbTicket.ID), - ShortName: dbTicket.Key.String, - Name: dbTicket.Key.String, - ChannelID: dbTicket.Channelid.String, - ProjectGit: dbTicket.ProjectGit.String, - BuildGit: dbTicket.BuildGit.String, - Cloud: dbTicket.Folder.String, - } } - return &proj, nil + + return &domain.Project{ + ID: strconv.Itoa(int(dbTicket.ID)), + Key: dbTicket.Key.String, + Name: dbTicket.Title.String, + ChannelID: dbTicket.Channelid.String, + ProjectGit: dbTicket.ProjectGit.String, + BuildGit: dbTicket.BuildGit.String, + Folder: dbTicket.Folder.String, + }, nil } // Saves current channel as project's channel; @@ -108,11 +110,11 @@ func (wc *WorkflowController) InitProjectInChannel(ctx context.Context, channelI return &domain.Project{ ID: string(dbTicket.ID), - ShortName: dbTicket.Key.String, + Key: dbTicket.Key.String, Name: dbTicket.Key.String, ChannelID: dbTicket.Channelid.String, ProjectGit: dbTicket.ProjectGit.String, BuildGit: dbTicket.BuildGit.String, - Cloud: dbTicket.Folder.String, + Folder: dbTicket.Folder.String, }, nil } diff --git a/internal/controller/control_task.go b/internal/controller/control_task.go index b992ca7..345ee28 100644 --- a/internal/controller/control_task.go +++ b/internal/controller/control_task.go @@ -3,7 +3,6 @@ package controller import ( "context" "fmt" - "os" "ticket-pimp/internal/domain" "ticket-pimp/internal/storage/db" "time" @@ -42,17 +41,16 @@ func (wc *WorkflowController) WriteTaskToDB(t *domain.Task) (*domain.Task, error // InitTask /* -Runs the following: - - Use WriteTaskToDB method to make a new task row in the db; - - init new discord bot instance; - - + Runs the following: + - Use WriteTaskToDB method to make a new task row in the db; + - init new discord bot instance; -Possible errors: - - db record couldn't be created; - - bot couldn't be inited; - - bot session couldn't be started; - - thread couldn't be started; - - first task message couldn't be edited; + Possible errors: + - db record couldn't be created; + - bot couldn't be inited; + - bot session couldn't be started; + - thread couldn't be started; + - first task message couldn't be edited; */ func (wc *WorkflowController) InitTask(t *domain.Task) (*domain.Task, error) { @@ -61,14 +59,7 @@ func (wc *WorkflowController) InitTask(t *domain.Task) (*domain.Task, error) { return nil, fmt.Errorf("unable to create task at the db: %v", err) } - // Инициализируем новый клиент дискорда - // [ ] Нездоровое получение параметров клиента из os.. - var ( - token = os.Getenv("DISCORD_TOKEN") - forumChannelID = os.Getenv("TASKS_CHANNEL") - ) - - s, err := discordgo.New("Bot " + token) + s, err := discordgo.New("Bot " + wc.conf.Discord.Token) if err != nil { return task, fmt.Errorf("unable to create discord session: %v", err) } @@ -100,9 +91,10 @@ func (wc *WorkflowController) InitTask(t *domain.Task) (*domain.Task, error) { } th, err := s.ForumThreadStartComplex( - forumChannelID, + wc.conf.Discord.IsTaskForum, &discordgo.ThreadStart{ - Name: fmt.Sprintf("Task ID: %d, by %s", task.ID, task.Creator), + Name: fmt.Sprintf("Task ID: %d, by %s", task.ID, task.Creator), + AppliedTags: []string{wc.conf.Discord.Tags[domain.NewTaskState()]}, }, &msg, ) @@ -126,25 +118,34 @@ func (wc *WorkflowController) UpdateTasksMessageID(ctx context.Context, msgID st return err } -func (wc *WorkflowController) UpdateTask(id string, opt int, user string) (*TaskConvertable, error) { +// UpdateTask +/* + - updates task by message Id + - with an action: + 0 for 'in progress' state; + 1 for 'done' state; + - and assignee.. +*/ +func (wc *WorkflowController) UpdateTask(messageId string, opt int, assignee string) (*TaskConvertable, error) { var ( err error dbtask db.Task ) + switch opt { case 0: dbtask, err = wc.q.StartTask(context.TODO(), db.StartTaskParams{ UpdatedAt: pgtype.Timestamptz{Time: time.Now(), InfinityModifier: 0, Valid: true}, - Assignee: pgtype.Text{String: user, Valid: true}, - Messageid: pgtype.Text{String: id, Valid: true}, + Assignee: pgtype.Text{String: assignee, Valid: true}, + Messageid: pgtype.Text{String: messageId, Valid: true}, }) return &TaskConvertable{&dbtask}, err case 1: dbtask, err = wc.q.CloseTask(context.TODO(), db.CloseTaskParams{ DeletedAt: pgtype.Timestamptz{Time: time.Now(), InfinityModifier: 0, Valid: true}, - Assignee: pgtype.Text{String: user, Valid: true}, - Messageid: pgtype.Text{String: id, Valid: true}, + Assignee: pgtype.Text{String: assignee, Valid: true}, + Messageid: pgtype.Text{String: messageId, Valid: true}, }) return &TaskConvertable{&dbtask}, err } diff --git a/internal/controller/controller.go b/internal/controller/controller.go index ecea95d..0d3bb7b 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -1,28 +1,28 @@ package controller import ( + "ticket-pimp/adapters" "ticket-pimp/internal/domain" - "ticket-pimp/internal/services" "ticket-pimp/internal/storage/db" - "github.com/bwmarrin/discordgo" "github.com/jackc/pgx/v5/pgxpool" ) type WorkflowController struct { - IGit services.IGit - ICloud services.ICloud - ICoda services.ICoda + IGit adapters.IGit + ICloud adapters.ICloud + ICoda adapters.ICoda pool *pgxpool.Pool q *db.Queries - ATags []discordgo.ForumTag + conf *domain.Config } -func NewWorkflowController( - git services.IGit, - cloud services.ICloud, - coda services.ICoda, +func NewApp( + git adapters.IGit, + cloud adapters.ICloud, + coda adapters.ICoda, pool *pgxpool.Pool, + conf *domain.Config, ) *WorkflowController { return &WorkflowController{ IGit: git, @@ -30,6 +30,7 @@ func NewWorkflowController( ICoda: coda, pool: pool, q: db.New(pool), + conf: conf, } } diff --git a/internal/domain/config.go b/internal/domain/config.go index 3659895..ef31927 100644 --- a/internal/domain/config.go +++ b/internal/domain/config.go @@ -53,6 +53,7 @@ type DiscordConfig struct { Token string IsProjectChannel string IsTaskForum string + Tags map[TaskState]string } type ApplicationConfig struct { @@ -62,8 +63,9 @@ type ApplicationConfig struct { // InitConfig // InitConfig function reads provided file and setup envirmental variables; -func InitConfig(envFilePath string) Config { - err := godotenv.Load(envFilePath) +func InitConfig(env string) Config { + log.Printf("loading ENV from: %s", env) + err := godotenv.Load(env) if err != nil { log.Fatal("Error while loading env file") } diff --git a/internal/domain/models.go b/internal/domain/models.go index 48ec870..cc0201b 100644 --- a/internal/domain/models.go +++ b/internal/domain/models.go @@ -2,6 +2,7 @@ package domain import ( "fmt" + "strings" "time" ) @@ -11,6 +12,11 @@ type Folder struct { PrivateURL string // http://domain/apps/files/?dir=/temp/k OR http://domain/f/3333 } +type Response struct { + Folder *Folder + ErrMessage error +} + type CodaApplication struct { ID string `json:"id"` Summary string `json:"summary"` @@ -77,6 +83,18 @@ const ( done ) +func State(i int) TaskState { + switch i { + case 0: + return new + case 1: + return inprogress + case 2: + return done + } + return -1 +} + func NewTaskState() TaskState { return TaskState(0) } @@ -138,22 +156,31 @@ type Git struct { type Project struct { ID string `json:"id"` //15 - ShortName string `json:"shortName"` //key-15 + Key string `json:"shortName"` //key-15 Name string `json:"name"` //default project name ChannelID string `json:"channel_id"` //123412341234 ProjectGit string `json:"project_git"` //https://github.com/mobilerino/dap-108 BuildGit string `json:"build_git"` //https://github.com/mobilerino/dap-108-build - Cloud string `json:"cloud"` //http://82.151.222.22:7000/f/86658 + Folder string `json:"cloud"` //http://82.151.222.22:7000/f/86658 } func (p *Project) DiscordString() string { - return fmt.Sprintf( - "## Project info:\n> 🔑 key: %s\n> 📂 folder: %s\n> 👾 project git: %s\n> 🚀 build git: %s\n", - p.ShortName, - p.Cloud, - p.ProjectGit, - p.BuildGit, - ) + var builder strings.Builder + builder.WriteString(fmt.Sprintf("## Project `%s`:\n> 🔑 key: %s", p.Name, p.Key)) + + if p.Folder != "" { + builder.WriteString(fmt.Sprintf("\n> 📂 folder: %s", p.Folder)) + } + + if p.ProjectGit != "" { + builder.WriteString(fmt.Sprintf("\n> 👾 project git: %s", p.ProjectGit)) + } + + if p.BuildGit != "" { + builder.WriteString(fmt.Sprintf("\n> 🚀 build git: %s", p.BuildGit)) + } + + return builder.String() } diff --git a/internal/domain/telegram.go b/internal/domain/telegram.go deleted file mode 100644 index 11cbaa3..0000000 --- a/internal/domain/telegram.go +++ /dev/null @@ -1,7 +0,0 @@ -package domain - -type TgUser struct { - ID string - Name string - TgLink string -} diff --git a/internal/services/client.go b/internal/external/client.go similarity index 97% rename from internal/services/client.go rename to internal/external/client.go index 8d56dcd..c51f56a 100644 --- a/internal/services/client.go +++ b/internal/external/client.go @@ -1,4 +1,4 @@ -package services +package external import ( "fmt" diff --git a/internal/services/cloud.go b/internal/external/cloud.go similarity index 92% rename from internal/services/cloud.go rename to internal/external/cloud.go index b5a748e..152e43d 100644 --- a/internal/services/cloud.go +++ b/internal/external/cloud.go @@ -1,4 +1,4 @@ -package services +package external import ( "errors" @@ -16,10 +16,6 @@ type Cloud struct { Config domain.CloudConfig } -type ICloud interface { - CreateFolder(name string) Response -} - func NewCloud(conf domain.CloudConfig) *Cloud { client := NewClient(). @@ -35,13 +31,8 @@ func NewCloud(conf domain.CloudConfig) *Cloud { } } -type Response struct { - Folder *domain.Folder - ErrMessage error -} - -func (c *Cloud) CreateFolder(name string) Response { - var R Response +func (c *Cloud) CreateFolder(name string) domain.Response { + var R domain.Response rootDir := c.Config.RootDir user := c.Config.User diff --git a/internal/services/coda.go b/internal/external/coda.go similarity index 70% rename from internal/services/coda.go rename to internal/external/coda.go index 9c10b61..6d04afe 100644 --- a/internal/services/coda.go +++ b/internal/external/coda.go @@ -1,8 +1,10 @@ -package services +package external import ( + "errors" "fmt" "log" + "strings" "ticket-pimp/internal/domain" "time" @@ -14,14 +16,7 @@ type Coda struct { Config domain.CodaConfig } -type ICoda interface { - ListDocs() - CreateApp(task domain.CodaApplication) - CreateTask(title string, desc string, creatorName string, creatorID string) (string, error) - GetRowLink(id string) (string, error) -} - -func NewCodaClient(conf domain.CodaConfig) *Coda { +func NewCoda(conf domain.CodaConfig) *Coda { client := NewClient(). SetTimeout(15 * time.Second). @@ -54,14 +49,49 @@ func (c *Coda) ListDocs() { log.Print(resp) } -func (c *Coda) CreateApp(task domain.CodaApplication) { - resp, _ := c.R(). +type CodaWebhookResponse struct { + ReqID string `json:"requestId"` +} + +func (c *Coda) CreateApp(task domain.CodaApplication) (string, error) { + + var whResponse CodaWebhookResponse + c.R(). SetBody(task). SetContentType("application/json"). + SetSuccessResult(&whResponse). SetBearerAuthToken(c.Config.Develop). Post("/docs/Ic3IZpQ3Wk/hooks/automation/grid-auto-NlUwM7F7Cr") - fmt.Print(resp) + if whResponse.ReqID == "" { + return "", errors.New("coda responded w/o mutate id") + } + + var ( + // mutate string + sep string = ":" + ) + + if !strings.Contains(whResponse.ReqID, sep) { + return "", fmt.Errorf("unexpected coda response: %s", whResponse.ReqID) + } + + // arr := strings.Split(whResponse.ReqID, sep) + // if arr[0] == "mutate" { + // mutate = arr[1] + // } + + // mutateResponse, err := c.R(). + // SetContentType("application/json"). + // SetBearerAuthToken(c.Config.Develop). + // Get(fmt.Sprintf("/mutationStatus/%s", mutate)) + + // if err != nil { + // return "", fmt.Errorf("unable to get coda mutate result: %s", mutate) + // } + + // _ = mutateResponse + return whResponse.ReqID, nil } func (c *Coda) CreateTask(title string, desc string, creatorName string, creatorID string) (string, error) { diff --git a/internal/external/dummy_telegram.go b/internal/external/dummy_telegram.go new file mode 100644 index 0000000..867a311 --- /dev/null +++ b/internal/external/dummy_telegram.go @@ -0,0 +1,44 @@ +package external + +import ( + "ticket-pimp/internal/domain" + "time" +) + +type DummyTelegram struct { + *CommonClient + config domain.TelegramConfig +} + +func NewDummyClient(conf domain.TelegramConfig) *DummyTelegram { + + client := NewClient(). + SetTimeout(5 * time.Second) + + return &DummyTelegram{ + CommonClient: &CommonClient{ + Client: client, + }, + config: conf, + } +} + +type TelegramMessage struct { + ChatID string `json:"chat_id"` + Text string `json:"text"` + DisableNotification bool `json:"disable_notification"` + ParseMode string `json:"parse_mode"` + DisablePreview bool `json:"disable_web_page_preview"` +} + +func (tg *DummyTelegram) DummyNotification(id string, text string) { + tg.R(). + SetBody(&TelegramMessage{ + ChatID: id, + Text: text, + DisableNotification: true, + ParseMode: "HTML", + DisablePreview: true, + }). + Post("https://api.telegram.org/bot" + tg.config.Token + "/sendMessage") +} diff --git a/internal/services/git.go b/internal/external/git.go similarity index 92% rename from internal/services/git.go rename to internal/external/git.go index 1778cce..8bf4aa3 100644 --- a/internal/services/git.go +++ b/internal/external/git.go @@ -1,4 +1,4 @@ -package services +package external import ( "fmt" @@ -13,10 +13,6 @@ type Git struct { conf *domain.GitConfig } -type IGit interface { - CreateRepo(name string) (*domain.Git, error) -} - func NewGit(conf domain.GitConfig) *Git { headers := map[string]string{ "Accept": "application/vnd.github+json", @@ -90,7 +86,6 @@ func (gb *Git) defaultGroupAsCollaborator(git *domain.Git) (*domain.Git, error) Perm: "push", } - // respURL := "/orgs/mobilerino/teams/devs/repos/mobilerino/" + git.Name respURL := fmt.Sprintf("/orgs/%s/teams/devs/repos/%s/%s", gb.conf.OrgName, gb.conf.OrgName, git.Name) resp, _ := gb.R(). diff --git a/internal/storage/migrate/0002_projectName_to_tickets.sql b/internal/storage/migrate/0002_projectName_to_tickets.sql new file mode 100644 index 0000000..9d88f53 --- /dev/null +++ b/internal/storage/migrate/0002_projectName_to_tickets.sql @@ -0,0 +1,7 @@ +-- +migrate Up +ALTER TABLE tickets +ADD COLUMN title VARCHAR(100); + +-- +migrate Down +ALTER TABLE tickets +DROP COLUMN title; \ No newline at end of file diff --git a/internal/storage/sqlc/queries.sql b/internal/storage/sqlc/queries.sql index cae5e62..a721d02 100644 --- a/internal/storage/sqlc/queries.sql +++ b/internal/storage/sqlc/queries.sql @@ -9,9 +9,9 @@ RETURNING *; -- name: CreateTicket :one INSERT INTO tickets ( - key, channelID + key, channelID, title ) VALUES ( - $1, $2 + $1, $2, $3 ) RETURNING *;