From e0ba5347a10a5cc434a69151e5dde40b757d60f8 Mon Sep 17 00:00:00 2001 From: Neur0toxine Date: Fri, 10 May 2024 11:46:43 +0300 Subject: [PATCH] poker implementation --- .env.dist | 2 + compose.yml | 2 +- internal/app/app.go | 65 +++- internal/config/config.go | 2 + internal/handler/callback_query_handler.go | 310 ++++++++++++++++-- internal/handler/chat/poll.go | 28 -- .../handler/chat_member_updated_handler.go | 56 ++-- internal/handler/group/poll.go | 109 ++++++ internal/handler/keyboard.go | 131 -------- internal/handler/new_message_handler.go | 34 +- internal/handler/store/init.go | 4 +- internal/handler/store/polls.go | 37 ++- internal/handler/util/keyboard.go | 191 +++++++++++ internal/handler/util/payload.go | 30 +- internal/integration/factory.go | 5 +- internal/integration/iface/integration.go | 3 +- internal/integration/null/null.go | 8 +- internal/integration/redmine/api/client.go | 50 ++- internal/integration/redmine/api/dto.go | 71 ++-- internal/integration/redmine/redmine.go | 56 +++- internal/locale/app.en.yaml | 12 + internal/locale/app.ru.yaml | 13 + internal/locale/localizer.go | 4 +- 23 files changed, 908 insertions(+), 315 deletions(-) delete mode 100644 internal/handler/chat/poll.go create mode 100644 internal/handler/group/poll.go delete mode 100644 internal/handler/keyboard.go create mode 100644 internal/handler/util/keyboard.go diff --git a/.env.dist b/.env.dist index c845864..93c9f12 100644 --- a/.env.dist +++ b/.env.dist @@ -1,3 +1,5 @@ POKER_BOT_POSTGRES_DSN=postgres://app:app@db:5432/app?sslmode=disable POKER_BOT_TELEGRAM_TOKEN=token +POKER_BOT_WEBHOOK_URL=https://vegapokerbot.proxy.neur0tx.site/webhook +POKER_BOT_LISTEN=:3333 POKER_BOT_DEBUG=false diff --git a/compose.yml b/compose.yml index a363b14..81fb1a6 100644 --- a/compose.yml +++ b/compose.yml @@ -23,7 +23,7 @@ services: environment: GOCACHE: /go ports: - - ${MG_TELEGRAM_ADDRESS:-8090}:8090 + - 3333:3333 command: make build run networks: diff --git a/internal/app/app.go b/internal/app/app.go index 7074ced..0e38536 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -9,6 +9,8 @@ import ( "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/logger" "github.com/mymmrac/telego" "go.uber.org/zap" + "gorm.io/gorm" + "net/url" ) type App struct { @@ -18,6 +20,7 @@ type App struct { Config *config.Config Repositories *db.Repositories Handlers map[handler.Type]handler.Handler + db *gorm.DB } func (a *App) Start() error { @@ -41,7 +44,10 @@ func (a *App) Start() error { } a.initHandlers() a.Logger.Info("Vega Poker Bot is running") - return a.longPoll() + if a.Config.Listen == "" || !a.isValidURL(a.Config.WebhookURL) { + return a.longPoll() + } + return a.listenWebhook() } func (a *App) Log() *zap.SugaredLogger { @@ -128,23 +134,60 @@ func (a *App) handler(update telego.Update) handler.Handler { return a.Handlers[handler.Noop] } +func (a *App) processUpdate(update telego.Update) { + defer func() { + if r := recover(); r != nil { + a.Logger.Errorf("recovered from panic inside the handler: %v", r) + } + }() + if err := a.handler(update).Handle(update); err != nil { + a.Logger.Errorf("error while handling the update: %s", err) + } +} + func (a *App) longPoll() error { + _ = a.Telegram.DeleteWebhook(&telego.DeleteWebhookParams{}) updates, err := a.Telegram.UpdatesViaLongPolling(nil) if err != nil { return err } defer a.Telegram.StopLongPolling() for update := range updates { - go func() { - defer func() { - if r := recover(); r != nil { - a.Logger.Errorf("recovered from panic inside the handler: %v", r) - } - }() - if err := a.handler(update).Handle(update); err != nil { - a.Logger.Errorf("error while handling the update: %s", err) - } - }() + go a.processUpdate(update) } return nil } + +func (a *App) listenWebhook() error { + uri, _ := url.Parse(a.Config.WebhookURL) + err := a.Telegram.SetWebhook(&telego.SetWebhookParams{ + URL: a.Config.WebhookURL, + }) + if err != nil { + return err + } + + a.Logger.Debugf("fetching updates from webhook: %s", uri.Path) + updates, err := a.Telegram.UpdatesViaWebhook(uri.Path) + if err != nil { + return err + } + go func() { + _ = a.Telegram.StartWebhook(a.Config.Listen) + }() + defer func() { + _ = a.Telegram.StopWebhook() + }() + for update := range updates { + go a.processUpdate(update) + } + return nil +} + +func (a *App) isValidURL(uri string) bool { + if uri == "" { + return false + } + _, err := url.Parse(uri) + return err == nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 668cabf..bf29f17 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,6 +11,8 @@ import ( type Config struct { PostgresDSN string `default:"" env:"POSTGRES_DSN" yaml:"postgres_dsn" toml:"postgres_dsn"` TelegramToken string `default:"" env:"TELEGRAM_TOKEN" yaml:"telegram_token" toml:"telegram_token"` + WebhookURL string `default:"" env:"WEBHOOK_URL" yaml:"webhook_url" toml:"webhook_url"` + Listen string `default:"" env:"LISTEN" yaml:"listen" toml:"listen"` Debug bool `default:"false" env:"DEBUG" yaml:"debug" toml:"debug"` } diff --git a/internal/handler/callback_query_handler.go b/internal/handler/callback_query_handler.go index a80baac..4e80fa4 100644 --- a/internal/handler/callback_query_handler.go +++ b/internal/handler/callback_query_handler.go @@ -2,35 +2,289 @@ package handler import ( "encoding/json" - "errors" + "fmt" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/db/model" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/iface" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/store" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/util" + "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/integration" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/locale" "github.com/mymmrac/telego" tu "github.com/mymmrac/telego/telegoutil" + "strings" ) type CallbackQueryHandler struct { - app iface.App + iface.Base } func NewCallbackQueryHandler(app iface.App) Handler { - return &CallbackQueryHandler{app: app} + return &CallbackQueryHandler{iface.NewBase(app, 0, 0)} } func (h *CallbackQueryHandler) Handle(wh telego.Update) error { cq := wh.CallbackQuery - user, err := h.app.DB().ForUser().ByTelegramID(cq.From.ID) + if cq.Message.GetChat().Type == telego.ChatTypePrivate { + user, err := h.App.DB().ForUser().ByTelegramID(cq.From.ID) + if err != nil { + return err + } + if user != nil && user.ID > 0 { + return h.handleSetupKey(cq, user) + } + + return nil + } + + var pl util.Payload + if err := json.Unmarshal([]byte(cq.Data), &pl); err != nil { + return err + } + chat, err := h.App.DB().ForChat().ByTelegramID(pl.Chat) if err != nil { return err } - if user != nil && user.ID > 0 { - return h.handleSetupKey(cq, user) + user, err := h.App.DB().ForUser().ByID(chat.UserID) + if err != nil { + return err } - // @todo implement poker polls handling. - return errors.New("not implemented yet") + loc := h.Localizer(user.Language) + switch pl.Action { + case util.PayloadActionVote: + poll, found, err := h.findPollByMessage(cq.Message, loc) + if err != nil { + return err + } + if !found { + return nil + } + return h.handleVote(cq.From, chat, cq.Message, poll, pl, loc) + case util.PayloadActionVoteFinish: + poll, found, err := h.findPollByMessage(cq.Message, loc) + if err != nil { + return err + } + if !found { + return nil + } + return h.handleVoteFinish(cq.From, chat, cq.Message, poll, pl, loc) + case util.PayloadActionVoteSendRedmine: + poll, found, err := h.findPollByMessage(cq.Message, loc) + if err != nil { + return err + } + if !found { + return nil + } + return h.handleVoteSendRedmine(cq.From, chat, cq.Message, poll, pl, loc) + default: + } + + return nil +} + +func (h *CallbackQueryHandler) handleVoteFinish(from telego.User, chat *model.Chat, + msg telego.MaybeInaccessibleMessage, poll store.PollState, pl util.Payload, loc locale.Localizer) error { + dotOrColon := "." + if poll.Subject != "" { + dotOrColon = ": " + } + if len(poll.Votes) == 0 { + store.Polls.Delete(msg.GetMessageID()) + _, err := h.App.TG().EditMessageText(&telego.EditMessageTextParams{ + ChatID: tu.ID(pl.Chat), + MessageID: msg.GetMessageID(), + Text: fmt.Sprintf("%s\n%s\n%s", + loc.Template("poker_start", map[string]interface{}{ + "Name": poll.Initiator, + "DotOrColon": dotOrColon, + "Subject": poll.Subject, + }), + loc.Template("poker_ended", map[string]interface{}{"Name": from.Username}), + loc.Message("poker_ended_no_results"), + ), + ParseMode: telego.ModeMarkdown, + }) + h.App.Log().Debugf("finished poll: %#v", poll) + return err + } + + poll.Calculate() + store.Polls.Set(msg.GetMessageID(), poll) + _, err := h.App.TG().EditMessageText(&telego.EditMessageTextParams{ + ChatID: tu.ID(pl.Chat), + MessageID: msg.GetMessageID(), + Text: fmt.Sprintf("%s\n%s\n\n%s\n%s\n\n%s", + loc.Template("poker_start", map[string]interface{}{ + "Name": poll.Initiator, + "DotOrColon": dotOrColon, + "Subject": poll.Subject, + }), + loc.Template("poker_ended", map[string]interface{}{"Name": from.Username}), + loc.Message("voted"), + h.voters(poll.Votes, loc), + loc.Template("poker_results", poll.Result), + ), + ParseMode: telego.ModeMarkdown, + ReplyMarkup: h.getPollAfterEndKeyboard(chat, poll, loc), + }) + h.App.Log().Debugf("finished poll: %#v", poll) + return err +} + +func (h *CallbackQueryHandler) handleVoteSendRedmine(from telego.User, chat *model.Chat, + msg telego.MaybeInaccessibleMessage, poll store.PollState, pl util.Payload, loc locale.Localizer) error { + dotOrColon := "." + if poll.Subject != "" { + dotOrColon = ": " + } + updateMessage := func(tag string) error { + _, err := h.App.TG().EditMessageText(&telego.EditMessageTextParams{ + ChatID: tu.ID(pl.Chat), + MessageID: msg.GetMessageID(), + Text: fmt.Sprintf("%s\n%s\n\n%s\n%s\n\n%s\n\n%s", + loc.Template("poker_start", map[string]interface{}{ + "Name": poll.Initiator, + "DotOrColon": dotOrColon, + "Subject": poll.Subject, + }), + loc.Template("poker_ended", map[string]interface{}{"Name": from.Username}), + loc.Message("voted"), + h.voters(poll.Votes, loc), + loc.Template("poker_results", poll.Result), + loc.Message(tag), + ), + ParseMode: telego.ModeMarkdown, + }) + return err + } + + h.App.Log().Debugf("received vote send for poll: %#v", poll) + if !poll.CanRedmine || poll.TaskID == 0 { + store.Polls.Delete(msg.GetMessageID()) + h.App.Log().Errorf("cannot send info to Redmine for %d: CanRedmine: %t", poll.TaskID, poll.CanRedmine) + return updateMessage("poker_redmine_cannot_send") + } + integrationData, err := h.App.DB().ForIntegration().LoadForChatAndType(chat.ID, model.RedmineIntegration) + if err != nil { + store.Polls.Delete(msg.GetMessageID()) + h.App.Log().Errorf("cannot send info to Redmine for %d: %s", poll.TaskID, err) + return updateMessage("poker_redmine_cannot_send") + } + + err = integration.New(*integrationData, h.App.Log()).PushVoteResult(poll.TaskID, poll.Result.RoundHalf) + if err != nil { + store.Polls.Delete(msg.GetMessageID()) + h.App.Log().Errorf("cannot send info to Redmine for %d: %s", poll.TaskID, err) + return updateMessage("poker_redmine_cannot_send") + } + + return updateMessage("poker_redmine_sent") +} + +func (h *CallbackQueryHandler) handleVote(from telego.User, chat *model.Chat, + msg telego.MaybeInaccessibleMessage, poll store.PollState, pl util.Payload, loc locale.Localizer) error { + var found bool + for i, storedVote := range poll.Votes { + if storedVote.ID == from.ID { + storedVote.Name = h.pollMemberName(&from) + storedVote.Vote = pl.Vote().Vote + poll.Votes[i] = storedVote + found = true + break + } + } + if !found { + poll.Votes = append(poll.Votes, store.MemberVote{ + Member: util.Member{ + ID: from.ID, + Name: h.pollMemberName(&from), + }, + Vote: pl.Vote().Vote, + }) + } + store.Polls.Set(msg.GetMessageID(), poll) + + h.App.Log().Debugf("processed vote %#v for poll: %#v", pl.Vote(), poll) + dotOrColon := "." + if poll.Subject != "" { + dotOrColon = ": " + } + _, err := h.App.TG().EditMessageText(&telego.EditMessageTextParams{ + ChatID: tu.ID(pl.Chat), + MessageID: msg.GetMessageID(), + Text: fmt.Sprintf("%s\n%s\n%s", + loc.Template("poker_start", map[string]interface{}{ + "Name": poll.Initiator, + "DotOrColon": dotOrColon, + "Subject": poll.Subject, + }), + loc.Message("voted"), + h.votersWithoutVotes(poll.Votes, loc), + ), + ReplyMarkup: h.getPollKeyboard(chat, loc), + ParseMode: telego.ModeMarkdown, + }) + return err +} + +func (h *CallbackQueryHandler) pollMemberName(user *telego.User) string { + return fmt.Sprintf("%s (@%s)", strings.TrimRight(user.FirstName+" "+user.LastName, " "), user.Username) +} + +func (h *CallbackQueryHandler) voters(votes []store.MemberVote, loc locale.Localizer) string { + var sb strings.Builder + sb.Grow(20 * len(votes)) + for _, vote := range votes { + sb.WriteString(loc.Template("vote_result", map[string]interface{}{ + "Name": vote.Name, + "Vote": vote.Vote, + }) + "\n") + } + return strings.TrimRight(sb.String(), "\n") +} + +func (h *CallbackQueryHandler) votersWithoutVotes(votes []store.MemberVote, loc locale.Localizer) string { + var sb strings.Builder + sb.Grow(20 * len(votes)) + for _, vote := range votes { + sb.WriteString(loc.Template("voter", map[string]interface{}{"Name": vote.Name}) + "\n") + } + return strings.TrimRight(sb.String(), "\n") +} + +func (h *CallbackQueryHandler) getPollKeyboard(chat *model.Chat, loc locale.Localizer) *telego.InlineKeyboardMarkup { + switch chat.KeyboardType { + case model.StandardKeyboard: + return util.StandardVoteKeyboard(chat.TelegramID, loc) + case model.StoryPointsKeyboard: + return util.StoryPointsVoteKeyboard(chat.TelegramID, loc) + } + return nil +} + +func (h *CallbackQueryHandler) getPollAfterEndKeyboard(chat *model.Chat, poll store.PollState, loc locale.Localizer) *telego.InlineKeyboardMarkup { + rm, err := h.App.DB().ForIntegration().LoadForChatAndType(chat.ID, model.RedmineIntegration) + if err != nil { + return nil + } + if rm != nil && rm.ID > 0 && poll.CanRedmine && poll.TaskID > 0 { + return util.SendRedmineKeyboard(chat.TelegramID, poll.Result.RoundHalf, loc) + } + return nil +} + +func (h *CallbackQueryHandler) findPollByMessage(msg telego.MaybeInaccessibleMessage, loc locale.Localizer) (store.PollState, bool, error) { + poll, found := store.Polls.Get(msg.GetMessageID()) + if !found { + _, err := h.App.TG().EditMessageText(&telego.EditMessageTextParams{ + ChatID: tu.ID(msg.GetChat().ID), + MessageID: msg.GetMessageID(), + Text: loc.Message("cannot_find_poll"), + }) + return store.PollState{}, false, err + } + return poll, true, nil } func (h *CallbackQueryHandler) handleSetupKey(cq *telego.CallbackQuery, user *model.User) error { @@ -58,7 +312,7 @@ func (h *CallbackQueryHandler) handleSetupKey(cq *telego.CallbackQuery, user *mo } func (h *CallbackQueryHandler) handleChooseKeyboard(pl util.Payload, msgID int, user *model.User) error { - cr := h.app.DB().ForChat() + cr := h.App.DB().ForChat() result := pl.KeyboardChoice() if pl.User != user.TelegramID { return nil @@ -79,13 +333,13 @@ func (h *CallbackQueryHandler) handleChooseKeyboard(pl util.Payload, msgID int, if model.KeyboardType(result.Type) == model.StoryPointsKeyboard { kbTypeName = "sp_vote_keyboard" } - loc := h.app.Localizer(user.Language) - _, _ = h.app.TG().EditMessageText(&telego.EditMessageTextParams{ + loc := h.Localizer(user.Language) + _, _ = h.App.TG().EditMessageText(&telego.EditMessageTextParams{ ChatID: tu.ID(user.ChatID), MessageID: msgID, Text: loc.Template("chosen_keyboard", map[string]interface{}{"Name": loc.Message(kbTypeName)}), }) - _, err = h.app.TG().SendMessage(&telego.SendMessageParams{ + _, err = h.App.TG().SendMessage(&telego.SendMessageParams{ ChatID: tu.ID(user.ChatID), Text: loc.Message("ask_for_redmine"), ParseMode: telego.ModeMarkdown, @@ -106,16 +360,16 @@ func (h *CallbackQueryHandler) handleChooseKeyboard(pl util.Payload, msgID int, } func (h *CallbackQueryHandler) handleAnswerRedmine(answer util.Answer, chatID int64, msgID int, user *model.User) error { - loc := h.app.Localizer(user.Language) + loc := h.Localizer(user.Language) if !answer.Result { - _, _ = h.app.TG().EditMessageText(&telego.EditMessageTextParams{ + _, _ = h.App.TG().EditMessageText(&telego.EditMessageTextParams{ ChatID: tu.ID(user.ChatID), MessageID: msgID, Text: loc.Message("redmine_will_not_be_configured"), }) - return util.SendSetupDone(h.app.TG(), user.ChatID, loc) + return util.SendSetupDone(h.App.TG(), user.ChatID, loc) } - _, err := h.app.TG().EditMessageText(&telego.EditMessageTextParams{ + _, err := h.App.TG().EditMessageText(&telego.EditMessageTextParams{ ChatID: tu.ID(user.ChatID), MessageID: msgID, Text: loc.Message("please_send_redmine_url"), @@ -125,13 +379,13 @@ func (h *CallbackQueryHandler) handleAnswerRedmine(answer util.Answer, chatID in } func (h *CallbackQueryHandler) handleAnswerRedmineHours(answer util.Answer, chatID int64, msgID int, user *model.User) error { - loc := h.app.Localizer(user.Language) + loc := h.Localizer(user.Language) message := "redmine_hours_will_not_be_configured" if answer.Result { message = "redmine_hours_will_be_configured" } - _, _ = h.app.TG().EditMessageText(&telego.EditMessageTextParams{ + _, _ = h.App.TG().EditMessageText(&telego.EditMessageTextParams{ ChatID: tu.ID(user.ChatID), MessageID: msgID, Text: loc.Message(message), @@ -145,7 +399,7 @@ func (h *CallbackQueryHandler) handleAnswerRedmineHours(answer util.Answer, chat } } - _, err := h.app.TG().SendMessage(&telego.SendMessageParams{ + _, err := h.App.TG().SendMessage(&telego.SendMessageParams{ ChatID: tu.ID(user.ChatID), Text: loc.Message("should_send_poker_to_redmine"), ParseMode: telego.ModeMarkdown, @@ -167,16 +421,16 @@ func (h *CallbackQueryHandler) handleAnswerRedmineHours(answer util.Answer, chat } func (h *CallbackQueryHandler) handleAnswerRedmineSendResults(answer util.Answer, msgID int, user *model.User) error { - loc := h.app.Localizer(user.Language) + loc := h.Localizer(user.Language) if !answer.Result { - _, _ = h.app.TG().EditMessageText(&telego.EditMessageTextParams{ + _, _ = h.App.TG().EditMessageText(&telego.EditMessageTextParams{ ChatID: tu.ID(user.ChatID), MessageID: msgID, Text: loc.Message("redmine_poker_will_not_be_configured"), }) - return util.SendSetupDone(h.app.TG(), user.ChatID, loc) + return util.SendSetupDone(h.App.TG(), user.ChatID, loc) } - _, err := h.app.TG().EditMessageText(&telego.EditMessageTextParams{ + _, err := h.App.TG().EditMessageText(&telego.EditMessageTextParams{ ChatID: tu.ID(user.ChatID), MessageID: msgID, Text: loc.Message("specify_result_field"), @@ -187,14 +441,14 @@ func (h *CallbackQueryHandler) handleAnswerRedmineSendResults(answer util.Answer } func (h *CallbackQueryHandler) updateRedmineIntegration(rs *store.RedmineSetup, loc locale.Localizer, user *model.User) error { - dbChat, err := h.app.DB().ForChat().ByTelegramID(rs.Chat) + dbChat, err := h.App.DB().ForChat().ByTelegramID(rs.Chat) if dbChat == nil || dbChat.ID == 0 { - _ = util.SendInternalError(h.app.TG(), user.ChatID, loc) + _ = util.SendInternalError(h.App.TG(), user.ChatID, loc) return err } - savedRedmine, err := h.app.DB().ForIntegration().LoadForChatAndType(dbChat.ID, model.RedmineIntegration) + savedRedmine, err := h.App.DB().ForIntegration().LoadForChatAndType(dbChat.ID, model.RedmineIntegration) if savedRedmine == nil || savedRedmine.ID == 0 { - _ = util.SendInternalError(h.app.TG(), user.ChatID, loc) + _ = util.SendInternalError(h.App.TG(), user.ChatID, loc) return err } var shouldUpdate bool @@ -209,7 +463,7 @@ func (h *CallbackQueryHandler) updateRedmineIntegration(rs *store.RedmineSetup, } if shouldUpdate { savedRedmine.StoreRedmine(savedRS) - return h.app.DB().ForIntegration().Save(savedRedmine) + return h.App.DB().ForIntegration().Save(savedRedmine) } return nil } diff --git a/internal/handler/chat/poll.go b/internal/handler/chat/poll.go deleted file mode 100644 index 008d123..0000000 --- a/internal/handler/chat/poll.go +++ /dev/null @@ -1,28 +0,0 @@ -package chat - -import ( - "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/iface" - "github.com/mymmrac/telego" - tu "github.com/mymmrac/telego/telegoutil" -) - -type Poll struct { - iface.Base -} - -func NewPoll(app iface.App, userID, chatID int64) *Poll { - return &Poll{iface.NewBase(app, userID, chatID)} -} - -func (h *Poll) Handle(wh telego.Update) error { - if wh.Message.Chat.Type == telego.ChatTypePrivate { - _, err := h.App.TG().SendMessage(&telego.SendMessageParams{ - ChatID: tu.ID(wh.Message.Chat.ID), - Text: h.Localizer(wh.Message.From.LanguageCode).Message("use_this_command_in_group"), - ParseMode: telego.ModeMarkdown, - }) - return err - } - // @todo implement poker polls. - return nil -} diff --git a/internal/handler/chat_member_updated_handler.go b/internal/handler/chat_member_updated_handler.go index 7532b1a..3441c99 100644 --- a/internal/handler/chat_member_updated_handler.go +++ b/internal/handler/chat_member_updated_handler.go @@ -13,21 +13,21 @@ import ( const MaxChatMembers = 32 type ChatMemberUpdatedHandler struct { - app iface.App + iface.Base } func NewChatMemberUpdatedHandler(app iface.App) *ChatMemberUpdatedHandler { - return &ChatMemberUpdatedHandler{app: app} + return &ChatMemberUpdatedHandler{iface.NewBase(app, 0, 0)} } func (h *ChatMemberUpdatedHandler) Handle(wh telego.Update) error { cm := wh.MyChatMember - if !cm.OldChatMember.MemberIsMember() && cm.OldChatMember.MemberUser().ID == h.app.TGProfile().ID && - cm.NewChatMember.MemberIsMember() && cm.NewChatMember.MemberUser().ID == h.app.TGProfile().ID { + if !cm.OldChatMember.MemberIsMember() && cm.OldChatMember.MemberUser().ID == h.App.TGProfile().ID && + cm.NewChatMember.MemberIsMember() && cm.NewChatMember.MemberUser().ID == h.App.TGProfile().ID { return h.handleAddToChat(wh.MyChatMember.Chat) } - if cm.OldChatMember.MemberIsMember() && cm.OldChatMember.MemberUser().ID == h.app.TGProfile().ID && - !cm.NewChatMember.MemberIsMember() && cm.NewChatMember.MemberUser().ID == h.app.TGProfile().ID { + if cm.OldChatMember.MemberIsMember() && cm.OldChatMember.MemberUser().ID == h.App.TGProfile().ID && + !cm.NewChatMember.MemberIsMember() && cm.NewChatMember.MemberUser().ID == h.App.TGProfile().ID { return h.handleRemoveFromChat(wh.MyChatMember.Chat) } @@ -36,42 +36,42 @@ func (h *ChatMemberUpdatedHandler) Handle(wh telego.Update) error { } func (h *ChatMemberUpdatedHandler) handleAddToChat(tgChat telego.Chat) error { - cr := h.app.DB().ForChat() + cr := h.App.DB().ForChat() chat, err := cr.ByTelegramID(tgChat.ID) if err != nil { - _ = util.SendInternalError(h.app.TG(), tgChat.ID, nil) + _ = util.SendInternalError(h.App.TG(), tgChat.ID, nil) h.leaveChat(tgChat.ID) return err } user, err := h.getRegisteredAdmin(tgChat.ID) if err != nil { - _ = util.SendInternalError(h.app.TG(), tgChat.ID, nil) + _ = util.SendInternalError(h.App.TG(), tgChat.ID, nil) h.leaveChat(tgChat.ID) return err } if user == nil || user.ID == 0 { - _, err = h.app.TG().SendMessage(&telego.SendMessageParams{ + _, err = h.App.TG().SendMessage(&telego.SendMessageParams{ ChatID: tu.ID(tgChat.ID), - Text: h.app.Localizer(language.English.String()).Message("you_should_register_first"), + Text: h.Localizer(language.English.String()).Message("you_should_register_first"), ParseMode: telego.ModeMarkdown, }) h.leaveChat(tgChat.ID) return err } - loc := h.app.Localizer(user.Language) + loc := h.Localizer(user.Language) - totalMembers, err := h.app.TG().GetChatMemberCount(&telego.GetChatMemberCountParams{ + totalMembers, err := h.App.TG().GetChatMemberCount(&telego.GetChatMemberCountParams{ ChatID: tu.ID(tgChat.ID), }) if err != nil { - _ = util.SendInternalError(h.app.TG(), tgChat.ID, nil) + _ = util.SendInternalError(h.App.TG(), tgChat.ID, nil) h.leaveChat(tgChat.ID) return err } if *totalMembers > MaxChatMembers { - _, err = h.app.TG().SendMessage(&telego.SendMessageParams{ + _, err = h.App.TG().SendMessage(&telego.SendMessageParams{ ChatID: tu.ID(tgChat.ID), Text: loc.Template("too_many_members_in_the_group", map[string]interface{}{"Limit": MaxChatMembers}), ParseMode: telego.ModeMarkdown, @@ -87,27 +87,27 @@ func (h *ChatMemberUpdatedHandler) handleAddToChat(tgChat telego.Chat) error { } err := cr.Save(chat) if err != nil { - _ = util.SendInternalError(h.app.TG(), tgChat.ID, loc) + _ = util.SendInternalError(h.App.TG(), tgChat.ID, loc) h.leaveChat(tgChat.ID) return err } } else { chat.UserID = user.ID - err := h.app.DB().ForIntegration().DeleteForChat(chat.ID) + err := h.App.DB().ForIntegration().DeleteForChat(chat.ID) if err != nil { - _ = util.SendInternalError(h.app.TG(), tgChat.ID, loc) + _ = util.SendInternalError(h.App.TG(), tgChat.ID, loc) h.leaveChat(tgChat.ID) return err } err = cr.Save(chat) if err != nil { - _ = util.SendInternalError(h.app.TG(), tgChat.ID, loc) + _ = util.SendInternalError(h.App.TG(), tgChat.ID, loc) h.leaveChat(tgChat.ID) return err } } - _, _ = h.app.TG().SendMessage(&telego.SendMessageParams{ + _, _ = h.App.TG().SendMessage(&telego.SendMessageParams{ ChatID: tu.ID(user.ChatID), Text: loc.Template("bot_was_added", map[string]interface{}{"Name": tgChat.Title}), ParseMode: telego.ModeMarkdown, @@ -117,17 +117,17 @@ func (h *ChatMemberUpdatedHandler) handleAddToChat(tgChat telego.Chat) error { } func (h *ChatMemberUpdatedHandler) handleRemoveFromChat(tgChat telego.Chat) error { - cr := h.app.DB().ForChat() + cr := h.App.DB().ForChat() chat, err := cr.ByTelegramID(tgChat.ID) if err != nil { return err } if chat != nil && chat.ID > 0 { - user, _ := h.app.DB().ForUser().ByID(chat.UserID) + user, _ := h.App.DB().ForUser().ByID(chat.UserID) if user != nil && user.ID > 0 && user.ChatID > 0 { - _, _ = h.app.TG().SendMessage(&telego.SendMessageParams{ + _, _ = h.App.TG().SendMessage(&telego.SendMessageParams{ ChatID: tu.ID(user.ChatID), - Text: h.app.Localizer(user.Language).Template( + Text: h.Localizer(user.Language).Template( "bot_was_removed_from_group", map[string]interface{}{"Name": tgChat.Title}), ParseMode: telego.ModeMarkdown, }) @@ -138,7 +138,7 @@ func (h *ChatMemberUpdatedHandler) handleRemoveFromChat(tgChat telego.Chat) erro } func (h *ChatMemberUpdatedHandler) sendKeyboardChooser(chat telego.Chat, user *model.User, loc locale.Localizer) error { - _, err := h.app.TG().SendMessage(&telego.SendMessageParams{ + _, err := h.App.TG().SendMessage(&telego.SendMessageParams{ ChatID: tu.ID(user.ChatID), Text: loc.Template("choose_keyboard", map[string]interface{}{"Name": chat.Title}), ParseMode: telego.ModeMarkdown, @@ -165,7 +165,7 @@ func (h *ChatMemberUpdatedHandler) sendKeyboardChooser(chat telego.Chat, user *m } func (h *ChatMemberUpdatedHandler) getRegisteredAdmin(chatID int64) (*model.User, error) { - admins, err := h.app.TG().GetChatAdministrators(&telego.GetChatAdministratorsParams{ + admins, err := h.App.TG().GetChatAdministrators(&telego.GetChatAdministratorsParams{ ChatID: tu.ID(chatID), }) if err != nil { @@ -175,7 +175,7 @@ func (h *ChatMemberUpdatedHandler) getRegisteredAdmin(chatID int64) (*model.User for i, admin := range admins { adminIDs[i] = admin.MemberUser().ID } - dbAdmins, err := h.app.DB().ForUser().ByTelegramIDs(adminIDs) + dbAdmins, err := h.App.DB().ForUser().ByTelegramIDs(adminIDs) if err != nil { return nil, err } @@ -186,7 +186,7 @@ func (h *ChatMemberUpdatedHandler) getRegisteredAdmin(chatID int64) (*model.User } func (h *ChatMemberUpdatedHandler) leaveChat(chatID int64) { - _ = h.app.TG().LeaveChat(&telego.LeaveChatParams{ + _ = h.App.TG().LeaveChat(&telego.LeaveChatParams{ ChatID: tu.ID(chatID), }) } diff --git a/internal/handler/group/poll.go b/internal/handler/group/poll.go new file mode 100644 index 0000000..7190d57 --- /dev/null +++ b/internal/handler/group/poll.go @@ -0,0 +1,109 @@ +package group + +import ( + "fmt" + "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/db/model" + "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/iface" + "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/store" + "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/util" + "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/integration" + "github.com/mymmrac/telego" + tu "github.com/mymmrac/telego/telegoutil" + "golang.org/x/text/language" + "strings" +) + +type Poll struct { + iface.Base +} + +func NewPoll(app iface.App, userID, chatID int64) *Poll { + return &Poll{iface.NewBase(app, userID, chatID)} +} + +func (h *Poll) Handle(wh telego.Update) error { + if wh.Message.Chat.Type == telego.ChatTypePrivate { + _, err := h.App.TG().SendMessage(&telego.SendMessageParams{ + ChatID: tu.ID(wh.Message.Chat.ID), + Text: h.Localizer(wh.Message.From.LanguageCode).Message("use_this_command_in_group"), + ParseMode: telego.ModeMarkdown, + }) + return err + } + chat, err := h.App.DB().ForChat().ByTelegramIDWithIntegrations(wh.Message.Chat.ID) + if err != nil { + _ = util.SendInternalError(h.App.TG(), wh.Message.Chat.ID, h.Localizer(language.English.String())) + return err + } + user, err := h.App.DB().ForUser().ByID(chat.UserID) + if err != nil { + _ = util.SendInternalError(h.App.TG(), wh.Message.Chat.ID, h.Localizer(language.English.String())) + return err + } + loc := h.Localizer(user.Language) + _ = loc + if len(wh.Message.Entities) != 1 || + (len(wh.Message.Entities) == 1 && wh.Message.Entities[0].Type != telego.EntityTypeBotCommand) || + (len(wh.Message.Entities) == 1 && wh.Message.Entities[0].Offset != 0) { + return nil + } + + var ( + taskID int + canRedmine bool + ) + taskInfo := strings.TrimSpace(wh.Message.Text[wh.Message.Entities[0].Length:]) + if taskInfo != "" { + for _, integrationData := range chat.Integrations { + id, info := integration.New(integrationData, h.App.Log()).GetTaskInfo(taskInfo) + if id > 0 { + taskID = id + taskInfo = info + canRedmine = integrationData.Type == model.RedmineIntegration + + break + } + } + } + + dotOrColon := "." + if taskInfo != "" { + dotOrColon = ": " + } + + var kb telego.ReplyMarkup + switch chat.KeyboardType { + case model.StandardKeyboard: + kb = util.StandardVoteKeyboard(wh.Message.Chat.ID, loc) + case model.StoryPointsKeyboard: + kb = util.StoryPointsVoteKeyboard(wh.Message.Chat.ID, loc) + } + + msg, err := h.App.TG().SendMessage(&telego.SendMessageParams{ + ChatID: tu.ID(wh.Message.Chat.ID), + Text: fmt.Sprintf("%s\n%s", + loc.Template("poker_start", map[string]interface{}{ + "Name": wh.Message.From.Username, + "DotOrColon": dotOrColon, + "Subject": taskInfo, + }), + loc.Message("no_votes_yet"), + ), + ReplyMarkup: kb, + ParseMode: telego.ModeMarkdown, + }) + if err != nil { + return err + } + + store.Polls.Set(msg.MessageID, store.PollState{ + Initiator: wh.Message.From.Username, + Subject: taskInfo, + TaskID: taskID, + Votes: []store.MemberVote{}, + Result: store.PollResult{}, + CanRedmine: canRedmine, + }) + + return nil +} diff --git a/internal/handler/keyboard.go b/internal/handler/keyboard.go deleted file mode 100644 index 3fe73e2..0000000 --- a/internal/handler/keyboard.go +++ /dev/null @@ -1,131 +0,0 @@ -package handler - -import "github.com/mymmrac/telego" - -func StandardVoteKeyboard() *telego.InlineKeyboardMarkup { - return &telego.InlineKeyboardMarkup{ - InlineKeyboard: [][]telego.InlineKeyboardButton{ - { - { - Text: "0", - }, - { - Text: "0.5", - }, - { - Text: "1", - }, - { - Text: "2", - }, - }, - { - - { - Text: "3", - }, - { - Text: "5", - }, - { - Text: "8", - }, - { - Text: "13", - }, - }, - { - - { - Text: "20", - }, - { - Text: "40", - }, - { - Text: "100", - }, - { - Text: "?", - }, - }, - }, - } -} - -func StoryPointsVoteKeyboard() *telego.InlineKeyboardMarkup { - return &telego.InlineKeyboardMarkup{ - InlineKeyboard: [][]telego.InlineKeyboardButton{ - { - { - Text: "0.5", - }, - { - Text: "1", - }, - { - Text: "1.5", - }, - { - Text: "2", - }, - }, - { - { - Text: "2.5", - }, - { - Text: "3", - }, - { - Text: "3.5", - }, - { - Text: "4", - }, - }, - { - { - Text: "4.5", - }, - { - Text: "5", - }, - { - Text: "5.5", - }, - { - Text: "6", - }, - }, - { - { - Text: "6.5", - }, - { - Text: "7", - }, - { - Text: "7.5", - }, - { - Text: "8", - }, - }, - { - { - Text: "8.5", - }, - { - Text: "9", - }, - { - Text: "9.5", - }, - { - Text: "10", - }, - }, - }, - } -} diff --git a/internal/handler/new_message_handler.go b/internal/handler/new_message_handler.go index 8b2d18e..cd8ea5b 100644 --- a/internal/handler/new_message_handler.go +++ b/internal/handler/new_message_handler.go @@ -1,8 +1,7 @@ package handler import ( - "encoding/json" - "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/chat" + "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/group" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/iface" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/store" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/util" @@ -11,39 +10,34 @@ import ( ) type MessageHandler struct { - app iface.App + iface.Base } func NewMessageHandler(app iface.App) Handler { - return &MessageHandler{app: app} + return &MessageHandler{iface.NewBase(app, 0, 0)} } func (h *MessageHandler) Handle(wh telego.Update) error { - if wh.Message.From != nil && - wh.Message.Chat.Type == telego.ChatTypePrivate { - if util.MatchCommand("poll", wh.Message) { - return chat.NewPoll(h.app, wh.Message.From.ID, wh.Message.Chat.ID).Handle(wh) - } + if wh.Message.From == nil { + return nil + } + if wh.Message.Chat.Type == telego.ChatTypePrivate { if util.MatchCommand("start", wh.Message) { - return wizard.NewRegister(h.app, wh.Message.From.ID, wh.Message.Chat.ID).Handle(wh) + return wizard.NewRegister(h.App, wh.Message.From.ID, wh.Message.Chat.ID).Handle(wh) } if util.MatchCommand("help", wh.Message) { - return wizard.NewHelpCommand(h.app, wh.Message.From.ID, wh.Message.Chat.ID).Handle(wh) + return wizard.NewHelpCommand(h.App, wh.Message.From.ID, wh.Message.Chat.ID).Handle(wh) } setup, found := store.RedmineSetups.Get(wh.Message.Chat.ID) if found { - return wizard.NewRedmineSetup(h.app, wh.Message.From.ID, wh.Message.Chat.ID, setup).Handle(wh) + return wizard.NewRedmineSetup(h.App, wh.Message.From.ID, wh.Message.Chat.ID, setup).Handle(wh) } - - return wizard.NewUnknownCommand(h.app, wh.Message.From.ID, wh.Message.Chat.ID).Handle(wh) } - // TODO: Remove debug statement below. - h.app.Log().Debugf("New Message: %s", func(msg *telego.Message) string { - data, _ := json.Marshal(msg) - return string(data) - }(wh.Message)) + if util.MatchCommand("poll", wh.Message) { + return group.NewPoll(h.App, wh.Message.From.ID, wh.Message.Chat.ID).Handle(wh) + } - return nil + return wizard.NewUnknownCommand(h.App, wh.Message.From.ID, wh.Message.Chat.ID).Handle(wh) } diff --git a/internal/handler/store/init.go b/internal/handler/store/init.go index 140ef6d..ee7f117 100644 --- a/internal/handler/store/init.go +++ b/internal/handler/store/init.go @@ -6,8 +6,8 @@ import ( ) func init() { - cache, err := otter.MustBuilder[int64, PollState](10_000). - Cost(func(key int64, value PollState) uint32 { + cache, err := otter.MustBuilder[int, PollState](10_000). + Cost(func(key int, value PollState) uint32 { return 1 }). WithTTL(time.Hour * 24 * 7). diff --git a/internal/handler/store/polls.go b/internal/handler/store/polls.go index 15279a2..c5000b2 100644 --- a/internal/handler/store/polls.go +++ b/internal/handler/store/polls.go @@ -3,24 +3,43 @@ package store import ( "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/util" "github.com/maypok86/otter" + "math" ) type MemberVote struct { util.Member - Vote float32 + Vote float64 } type PollResult struct { - Max float32 - Min float32 - Avg float32 - Halved float32 + Max float64 + Min float64 + Avg float64 + RoundHalf float64 } type PollState struct { - Members []util.Member - Votes []MemberVote - Result PollResult + Initiator string + Subject string + TaskID int + Votes []MemberVote + Result PollResult + CanRedmine bool } -var Polls otter.Cache[int64, PollState] +func (p *PollState) Calculate() { + var sum float64 + for _, vote := range p.Votes { + sum += vote.Vote + if vote.Vote > p.Result.Max { + p.Result.Max = vote.Vote + } + if vote.Vote < p.Result.Min { + p.Result.Min = vote.Vote + } + } + p.Result.Avg = sum / float64(len(p.Votes)) + p.Result.RoundHalf = math.Round(p.Result.Avg/0.5) * 0.5 +} + +var Polls otter.Cache[int, PollState] diff --git a/internal/handler/util/keyboard.go b/internal/handler/util/keyboard.go new file mode 100644 index 0000000..c9de70c --- /dev/null +++ b/internal/handler/util/keyboard.go @@ -0,0 +1,191 @@ +package util + +import ( + "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/locale" + "github.com/mymmrac/telego" +) + +func StandardVoteKeyboard(chatID int64, loc locale.Localizer) *telego.InlineKeyboardMarkup { + return &telego.InlineKeyboardMarkup{ + InlineKeyboard: [][]telego.InlineKeyboardButton{ + { + { + Text: "0", + CallbackData: NewVotePayload(chatID, 0).String(), + }, + { + Text: "0.5", + CallbackData: NewVotePayload(chatID, 0.5).String(), + }, + { + Text: "1", + CallbackData: NewVotePayload(chatID, 1).String(), + }, + { + Text: "2", + CallbackData: NewVotePayload(chatID, 2).String(), + }, + }, + { + + { + Text: "3", + CallbackData: NewVotePayload(chatID, 3).String(), + }, + { + Text: "5", + CallbackData: NewVotePayload(chatID, 5).String(), + }, + { + Text: "8", + CallbackData: NewVotePayload(chatID, 8).String(), + }, + { + Text: "13", + CallbackData: NewVotePayload(chatID, 13).String(), + }, + }, + { + + { + Text: "20", + CallbackData: NewVotePayload(chatID, 20).String(), + }, + { + Text: "40", + CallbackData: NewVotePayload(chatID, 40).String(), + }, + { + Text: "100", + CallbackData: NewVotePayload(chatID, 100).String(), + }, + { + Text: "?", + CallbackData: NewVotePayload(chatID, 0).String(), + }, + }, + { + { + Text: loc.Message("finish_vote"), + CallbackData: NewVoteFinishPayload(chatID).String(), + }, + }, + }, + } +} + +func StoryPointsVoteKeyboard(chatID int64, loc locale.Localizer) *telego.InlineKeyboardMarkup { + return &telego.InlineKeyboardMarkup{ + InlineKeyboard: [][]telego.InlineKeyboardButton{ + { + { + Text: "0.5", + CallbackData: NewVotePayload(chatID, 0.5).String(), + }, + { + Text: "1", + CallbackData: NewVotePayload(chatID, 1).String(), + }, + { + Text: "1.5", + CallbackData: NewVotePayload(chatID, 1.5).String(), + }, + { + Text: "2", + CallbackData: NewVotePayload(chatID, 2).String(), + }, + }, + { + { + Text: "2.5", + CallbackData: NewVotePayload(chatID, 2.5).String(), + }, + { + Text: "3", + CallbackData: NewVotePayload(chatID, 3).String(), + }, + { + Text: "3.5", + CallbackData: NewVotePayload(chatID, 3.5).String(), + }, + { + Text: "4", + CallbackData: NewVotePayload(chatID, 4).String(), + }, + }, + { + { + Text: "4.5", + CallbackData: NewVotePayload(chatID, 4.5).String(), + }, + { + Text: "5", + CallbackData: NewVotePayload(chatID, 5).String(), + }, + { + Text: "5.5", + CallbackData: NewVotePayload(chatID, 5.5).String(), + }, + { + Text: "6", + CallbackData: NewVotePayload(chatID, 6).String(), + }, + }, + { + { + Text: "6.5", + CallbackData: NewVotePayload(chatID, 6.5).String(), + }, + { + Text: "7", + CallbackData: NewVotePayload(chatID, 7).String(), + }, + { + Text: "7.5", + CallbackData: NewVotePayload(chatID, 7.5).String(), + }, + { + Text: "8", + CallbackData: NewVotePayload(chatID, 8).String(), + }, + }, + { + { + Text: "8.5", + CallbackData: NewVotePayload(chatID, 8.5).String(), + }, + { + Text: "9", + CallbackData: NewVotePayload(chatID, 9).String(), + }, + { + Text: "9.5", + CallbackData: NewVotePayload(chatID, 9.5).String(), + }, + { + Text: "10", + CallbackData: NewVotePayload(chatID, 10).String(), + }, + }, + { + { + Text: loc.Message("finish_vote"), + CallbackData: NewVoteFinishPayload(chatID).String(), + }, + }, + }, + } +} + +func SendRedmineKeyboard(chatID int64, result float64, loc locale.Localizer) *telego.InlineKeyboardMarkup { + return &telego.InlineKeyboardMarkup{ + InlineKeyboard: [][]telego.InlineKeyboardButton{ + { + { + Text: loc.Message("send_result_redmine"), + CallbackData: NewRedmineSendResultPayload(chatID, result).String(), + }, + }, + }, + } +} diff --git a/internal/handler/util/payload.go b/internal/handler/util/payload.go index d4fb223..1e3bc6a 100644 --- a/internal/handler/util/payload.go +++ b/internal/handler/util/payload.go @@ -10,6 +10,8 @@ const ( PayloadActionYesNo = iota PayloadActionVote PayloadActionChooseKeyboard + PayloadActionVoteFinish + PayloadActionVoteSendRedmine ) type QuestionType uint8 @@ -21,10 +23,11 @@ const ( ) type Payload struct { - User int64 `json:"u,omitempty"` - Chat int64 `json:"c,omitempty"` - Action PayloadAction `json:"a"` - Data json.RawMessage `json:"d,omitempty"` + User int64 `json:"u,omitempty"` + Chat int64 `json:"c,omitempty"` + Message int `json:"m,omitempty"` + Action PayloadAction `json:"a"` + Data json.RawMessage `json:"d,omitempty"` } func (p Payload) String() string { @@ -66,7 +69,7 @@ type KBChooserData struct { } type Vote struct { - Vote float32 `json:"v"` + Vote float64 `json:"v"` } type Answer struct { @@ -85,7 +88,7 @@ func NewKeyboardChooserPayload(userID, chatID int64, kbType uint8) *Payload { } } -func NewVotePayload(chatID int64, vote float32) *Payload { +func NewVotePayload(chatID int64, vote float64) *Payload { return &Payload{ Action: PayloadActionVote, Chat: chatID, @@ -93,6 +96,13 @@ func NewVotePayload(chatID int64, vote float32) *Payload { } } +func NewVoteFinishPayload(chatID int64) *Payload { + return &Payload{ + Action: PayloadActionVoteFinish, + Chat: chatID, + } +} + func NewRedmineQuestionPayload(userID int64, chatID int64, isYes bool) *Payload { return &Payload{ Action: PayloadActionYesNo, @@ -120,6 +130,14 @@ func NewRedmineSendResultQuestionPayload(userID int64, chatID int64, isYes bool) } } +func NewRedmineSendResultPayload(chatID int64, resultToSend float64) *Payload { + return &Payload{ + Action: PayloadActionVoteSendRedmine, + Chat: chatID, + Data: marshal(Vote{Vote: resultToSend}), + } +} + func marshal(val interface{}) json.RawMessage { data, _ := json.Marshal(val) return data diff --git a/internal/integration/factory.go b/internal/integration/factory.go index 3651b3c..9a99fcf 100644 --- a/internal/integration/factory.go +++ b/internal/integration/factory.go @@ -5,12 +5,13 @@ import ( "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/integration/iface" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/integration/null" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/integration/redmine" + "go.uber.org/zap" ) -func New(dbModel model.Integration) iface.Integration { +func New(dbModel model.Integration, log *zap.SugaredLogger) iface.Integration { switch dbModel.Type { case model.RedmineIntegration: - return redmine.New(dbModel.Params) + return redmine.New(dbModel.Params, log) default: return null.New() } diff --git a/internal/integration/iface/integration.go b/internal/integration/iface/integration.go index a8a5c2f..795ba42 100644 --- a/internal/integration/iface/integration.go +++ b/internal/integration/iface/integration.go @@ -1,5 +1,6 @@ package iface type Integration interface { - GetTaskInfoText(input string) string + GetTaskInfo(input string) (int, string) + PushVoteResult(id int, result float64) error } diff --git a/internal/integration/null/null.go b/internal/integration/null/null.go index 7c1aeec..0a99d53 100644 --- a/internal/integration/null/null.go +++ b/internal/integration/null/null.go @@ -8,6 +8,10 @@ func New() iface.Integration { return &Null{} } -func (n *Null) GetTaskInfoText() string { - return "" +func (n *Null) GetTaskInfo(string) (int, string) { + return 0, "" +} + +func (n *Null) PushVoteResult(id int, result float64) error { + return nil } diff --git a/internal/integration/redmine/api/client.go b/internal/integration/redmine/api/client.go index a24cf4d..e40bc97 100644 --- a/internal/integration/redmine/api/client.go +++ b/internal/integration/redmine/api/client.go @@ -3,6 +3,8 @@ package api import ( "bytes" "encoding/json" + "errors" + "go.uber.org/zap" "io" "net/http" "strconv" @@ -14,19 +16,21 @@ type Client struct { URL string Key string client *http.Client + log *zap.SugaredLogger } -func New(url, key string) *Client { +func New(url, key string, log *zap.SugaredLogger) *Client { return &Client{ URL: strings.TrimSuffix(url, "/"), Key: key, client: &http.Client{Timeout: time.Second * 2}, + log: log, } } -func (c *Client) Issue(id uint64) (*Issue, error) { +func (c *Client) Issue(id int) (*Issue, error) { var resp IssueResponse - err := c.sendJSONRequest(http.MethodGet, "/issues/"+strconv.FormatUint(id, 10), nil, &resp) + _, err := c.sendJSONRequest(http.MethodGet, "/issues/"+strconv.Itoa(id), nil, &resp) if err != nil { return nil, err } @@ -36,31 +40,59 @@ func (c *Client) Issue(id uint64) (*Issue, error) { return resp.Issue, nil } -func (c *Client) sendRequest(method, path string, body io.Reader) (*http.Response, error) { +func (c *Client) UpdateIssue(id int, issue *Issue) error { + st, err := c.sendJSONRequest(http.MethodPut, "/issues/"+strconv.Itoa(id), IssueResponse{Issue: issue}, nil) + if err != nil { + return err + } + if st != http.StatusOK { + return errors.New("unexpected status code: " + strconv.Itoa(st)) + } + return nil +} + +func (c *Client) sendRequest(method, path string, headers map[string]string, body io.Reader) (*http.Response, error) { req, err := http.NewRequest(method, c.URL+path, body) if err != nil { return nil, err } req.Header.Set("X-Redmine-API-Key", c.Key) + for name, value := range headers { + req.Header.Set(name, value) + } return c.client.Do(req) } -func (c *Client) sendJSONRequest(method, path string, body, out interface{}) error { +func (c *Client) sendJSONRequest(method, path string, body, out interface{}) (int, error) { var buf io.Reader if body != nil { data, err := json.Marshal(body) if err != nil { - return err + return 0, err } buf = bytes.NewBuffer(data) } - resp, err := c.sendRequest(method, path+".json", buf) + path = path + ".json" + c.log.Debugf("[Redmine Request] %s %s; body: %s", method, path, func() string { + if buf != nil { + return buf.(*bytes.Buffer).String() + } + return "" + }()) + + resp, err := c.sendRequest(method, path, map[string]string{ + "Content-Type": "application/json", + }, buf) if err != nil { - return err + return 0, err } defer func() { _ = resp.Body.Close() }() - return json.NewDecoder(resp.Body).Decode(out) + if out != nil { + return resp.StatusCode, json.NewDecoder(resp.Body).Decode(out) + } + c.log.Debugf("[Redmine Response] code: %d; body: %#v", resp.StatusCode, out) + return resp.StatusCode, nil } diff --git a/internal/integration/redmine/api/dto.go b/internal/integration/redmine/api/dto.go index 78a9f4e..345c9b3 100644 --- a/internal/integration/redmine/api/dto.go +++ b/internal/integration/redmine/api/dto.go @@ -1,12 +1,14 @@ package api import ( + "encoding/json" + "strconv" "strings" "time" ) type ErrorResponse struct { - Errors []string `json:"errors"` + Errors []string `json:"errors,omitempty"` } func (e ErrorResponse) Error() string { @@ -34,26 +36,49 @@ type CustomField struct { } type Issue struct { - ID int `json:"id"` - Project NameWithID `json:"project"` - Tracker NameWithID `json:"tracker"` - Status NameWithID `json:"status"` - Priority NameWithID `json:"priority"` - Author NameWithID `json:"author"` - AssignedTo NameWithID `json:"assigned_to"` - Parent NameWithID `json:"parent"` - Subject string `json:"subject"` - Description string `json:"description"` - StartDate interface{} `json:"start_date"` - DueDate interface{} `json:"due_date"` - DoneRatio int `json:"done_ratio"` - IsPrivate bool `json:"is_private"` - EstimatedHours interface{} `json:"estimated_hours"` - TotalEstimatedHours interface{} `json:"total_estimated_hours"` - SpentHours float64 `json:"spent_hours"` - TotalSpentHours float64 `json:"total_spent_hours"` - CustomFields []CustomField `json:"custom_fields"` - CreatedOn time.Time `json:"created_on"` - UpdatedOn time.Time `json:"updated_on"` - ClosedOn interface{} `json:"closed_on"` + ID int `json:"id,omitempty"` + Project *NameWithID `json:"project,omitempty"` + Tracker *NameWithID `json:"tracker,omitempty"` + Status *NameWithID `json:"status,omitempty"` + Priority *NameWithID `json:"priority,omitempty"` + Author *NameWithID `json:"author,omitempty"` + AssignedTo *NameWithID `json:"assigned_to,omitempty"` + Parent *NameWithID `json:"parent,omitempty"` + Subject string `json:"subject,omitempty"` + Description string `json:"description,omitempty"` + StartDate interface{} `json:"start_date,omitempty"` + DueDate interface{} `json:"due_date,omitempty"` + DoneRatio int `json:"done_ratio,omitempty"` + IsPrivate bool `json:"is_private,omitempty"` + EstimatedHours Hours `json:"estimated_hours,omitempty"` + TotalEstimatedHours Hours `json:"total_estimated_hours,omitempty"` + SpentHours Hours `json:"spent_hours,omitempty"` + TotalSpentHours Hours `json:"total_spent_hours,omitempty"` + CustomFields []CustomField `json:"custom_fields,omitempty"` + CreatedOn *time.Time `json:"created_on,omitempty"` + UpdatedOn *time.Time `json:"updated_on,omitempty"` + ClosedOn interface{} `json:"closed_on,omitempty"` +} + +type Hours string + +func (h Hours) MarshalJSON() ([]byte, error) { + f, err := strconv.ParseFloat(string(h), 64) + if err != nil { + return nil, err + } + return []byte(strconv.FormatFloat(f, 'f', 2, 64)), nil +} + +func (h *Hours) UnmarshalJSON(b []byte) error { + f, err := strconv.ParseFloat(string(b), 64) + if err != nil { + var output string + defer func() { + *h = Hours(output) + }() + return json.Unmarshal(b, &output) + } + *h = Hours(strconv.FormatFloat(f, 'f', 2, 64)) + return nil } diff --git a/internal/integration/redmine/redmine.go b/internal/integration/redmine/redmine.go index 139340a..b85f510 100644 --- a/internal/integration/redmine/redmine.go +++ b/internal/integration/redmine/redmine.go @@ -5,6 +5,7 @@ import ( "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/integration/iface" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/integration/null" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/integration/redmine/api" + "go.uber.org/zap" "regexp" "strconv" "strings" @@ -15,10 +16,12 @@ var issueNumberMatcher = regexp.MustCompile(`(?m)\/issues\/(\d+)`) var sprintNames = []string{"sprint", "спринт"} type Redmine struct { - api *api.Client + api *api.Client + log *zap.SugaredLogger + spFieldName string } -func New(params map[string]interface{}) iface.Integration { +func New(params map[string]interface{}, log *zap.SugaredLogger) iface.Integration { uri, ok := params["url"] if !ok { return null.New() @@ -35,28 +38,57 @@ func New(params map[string]interface{}) iface.Integration { if !ok { return null.New() } - return &Redmine{api: api.New(uriString, keyString)} + var spField string + if spFieldName, ok := params["sp_field"]; ok { + if strField, ok := spFieldName.(string); ok { + spField = strField + } + } + return &Redmine{api: api.New(uriString, keyString, log), log: log, spFieldName: spField} } -func (r *Redmine) GetTaskInfoText(input string) string { +func (r *Redmine) GetTaskInfo(input string) (int, string) { taskNumber := r.getTaskNumber(input) if taskNumber == 0 { - return "" + r.log.Debugf("[Redmine] Cannot extract task number from %s", input) + return 0, "" } task, err := r.api.Issue(taskNumber) if err != nil { - return "" + r.log.Errorf("[Redmine] Cannot get Redmine issue %d: %s", taskNumber, err) + return 0, "" } sprint := r.getSprint(task.CustomFields) if sprint != "" { - sprint = fmt.Sprintf(" - **%s**", sprint) + sprint = fmt.Sprintf(" - _%s_", sprint) } - return fmt.Sprintf("[(%s) %s: %s](%s/issues/%d)%s", - task.Project.Name, task.Tracker.Name, task.Subject, r.api.URL, taskNumber, sprint) + taskInfo := strings.ReplaceAll(fmt.Sprintf("_(%s) %s_: [%s](%s/issues/%d)%s", + task.Project.Name, task.Tracker.Name, task.Subject, r.api.URL, taskNumber, sprint), "*", "") + return task.ID, "\n" + taskInfo + "" } -func (r *Redmine) getTaskNumber(input string) uint64 { - num, err := strconv.ParseUint(input, 10, 64) +func (r *Redmine) PushVoteResult(id int, result float64) error { + task, err := r.api.Issue(id) + if err != nil { + return err + } + issue := &api.Issue{ + EstimatedHours: api.Hours(strconv.FormatFloat(result*8, 'f', 0, 64)), + } + if r.spFieldName != "" { + for _, field := range task.CustomFields { + field := field + if field.Name == r.spFieldName { + field.Value = strconv.FormatFloat(result, 'f', 1, 64) + issue.CustomFields = []api.CustomField{field} + } + } + } + return r.api.UpdateIssue(id, issue) +} + +func (r *Redmine) getTaskNumber(input string) int { + num, err := strconv.Atoi(input) if err == nil && num > 0 { return num } @@ -64,7 +96,7 @@ func (r *Redmine) getTaskNumber(input string) uint64 { if len(matches) < 2 { return 0 } - number, err := strconv.ParseUint(matches[1], 10, 64) + number, err := strconv.Atoi(matches[1]) if err != nil { return 0 } diff --git a/internal/locale/app.en.yaml b/internal/locale/app.en.yaml index 87fb56e..032161c 100644 --- a/internal/locale/app.en.yaml +++ b/internal/locale/app.en.yaml @@ -28,3 +28,15 @@ should_send_estimated_hours_redmine: "🕑 Do you want the poker result to be co setup_done: "✔️ Done, now your chat is connected to the bot!\nUse the command /poll@vegapokerbot in the connected chat to start a vote." yes: "✔️ Yes" no: "✖️ No" +poker_start: "@{{.Name}} started poker{{.DotOrColon}}{{.Subject}}" +poker_ended: "Voting was finished by @{{.Name}}" +poker_ended_no_results: "_There were no votes._" +no_votes_yet: "_There are no votes yet..._" +voted: "Voted:" +voter: "• 👨‍💻 {{.Name}}" +vote_result: "• 👨‍💻 {{.Name}}: {{.Vote}}" +poker_results: "Result:\n• Max: {{.Max}}\n• Min: {{.Min}}\n• Average: {{.Avg}}\n• Rounded to half: {{.RoundHalf}}" +poker_redmine_cannot_send: "😢 _Cannot send results to Redmine._" +poker_redmine_sent: "📕 _Results were sent to Redmine._" +finish_vote: "🏁 Finish vote" +cannot_find_poll: "Cannot find this poll. Please start it again if you need it." diff --git a/internal/locale/app.ru.yaml b/internal/locale/app.ru.yaml index d2d2e52..eea8c1b 100644 --- a/internal/locale/app.ru.yaml +++ b/internal/locale/app.ru.yaml @@ -11,6 +11,7 @@ unknown_command: "❔ Неизвестная команда. Используй help_output: "📄 Доступные команды:\n\n• /start - регистрация в боте;\n• /poll@vegapokerbot - запускает poker в группе, работает только в подключенных группах;\n• /help - выводит эту справку." choose_at_least_one: "❌ Вы должны выбрать хотя бы одного участника." ask_for_redmine: "📕 Настроить интеграцию с Redmine?" +send_result_redmine: "📕 Отправить результат в Redmine" redmine_will_not_be_configured: "👌 Интеграция с Redmine не будет настроена. Вы не сможете отправлять результаты poker в Redmine или получать информацию о задачах." redmine_hours_will_not_be_configured: "👌 Оценка временных затрат не будет передаваться в Redmine." redmine_hours_will_be_configured: "👌 Оценка временных затрат будет передаваться в Redmine." @@ -28,3 +29,15 @@ should_send_estimated_hours_redmine: "🕑 Передавать в поле *О setup_done: "✔️ Готово, чат подключен к боту!\nИспользуйте команду /poll@vegapokerbot в подключенном чате для запуска голосования." yes: "✔️ Да" no: "✖️ Нет" +poker_start: "@{{.Name}} запустил покер{{.DotOrColon}}{{.Subject}}" +no_votes_yet: "_Пока еще никто не проголосовал..._" +poker_ended: "Голосование было завершено @{{.Name}}" +poker_ended_no_results: "_Никто не проголосовал._" +voted: "Проголосовали:" +voter: "• 👨‍💻 {{.Name}}" +vote_result: "• 👨‍💻 {{.Name}}: {{.Vote}}" +poker_results: "Результат:\n• Максимум: {{.Max}}\n• Минимум: {{.Min}}\n• Среднее: {{.Avg}}\n• Округление до половины: {{.RoundHalf}}" +poker_redmine_cannot_send: "😢 _Не удалось отправить результаты в Redmine._" +poker_redmine_sent: "📕 _Результаты были отправлены в Redmine._" +finish_vote: "🏁 Завершить голосование" +cannot_find_poll: "Не удалось найти это голосование. Запустите его заново если оно вам нужно." diff --git a/internal/locale/localizer.go b/internal/locale/localizer.go index 7df5f2a..c6853ca 100644 --- a/internal/locale/localizer.go +++ b/internal/locale/localizer.go @@ -4,7 +4,7 @@ import "github.com/nicksnyder/go-i18n/v2/i18n" type Localizer interface { Message(string) string - Template(string, map[string]interface{}) string + Template(string, interface{}) string } type localizer struct { @@ -15,7 +15,7 @@ func (l *localizer) Message(str string) string { return l.Template(str, nil) } -func (l *localizer) Template(str string, tpl map[string]interface{}) string { +func (l *localizer) Template(str string, tpl interface{}) string { return l.loc.MustLocalize(&i18n.LocalizeConfig{ MessageID: str, TemplateData: tpl,