poker implementation

This commit is contained in:
Pavel 2024-05-10 11:46:43 +03:00
parent 3a8a7c2a5c
commit e0ba5347a1
23 changed files with 908 additions and 315 deletions

View File

@ -1,3 +1,5 @@
POKER_BOT_POSTGRES_DSN=postgres://app:app@db:5432/app?sslmode=disable POKER_BOT_POSTGRES_DSN=postgres://app:app@db:5432/app?sslmode=disable
POKER_BOT_TELEGRAM_TOKEN=token POKER_BOT_TELEGRAM_TOKEN=token
POKER_BOT_WEBHOOK_URL=https://vegapokerbot.proxy.neur0tx.site/webhook
POKER_BOT_LISTEN=:3333
POKER_BOT_DEBUG=false POKER_BOT_DEBUG=false

View File

@ -23,7 +23,7 @@ services:
environment: environment:
GOCACHE: /go GOCACHE: /go
ports: ports:
- ${MG_TELEGRAM_ADDRESS:-8090}:8090 - 3333:3333
command: make build run command: make build run
networks: networks:

View File

@ -9,6 +9,8 @@ import (
"gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/logger" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/logger"
"github.com/mymmrac/telego" "github.com/mymmrac/telego"
"go.uber.org/zap" "go.uber.org/zap"
"gorm.io/gorm"
"net/url"
) )
type App struct { type App struct {
@ -18,6 +20,7 @@ type App struct {
Config *config.Config Config *config.Config
Repositories *db.Repositories Repositories *db.Repositories
Handlers map[handler.Type]handler.Handler Handlers map[handler.Type]handler.Handler
db *gorm.DB
} }
func (a *App) Start() error { func (a *App) Start() error {
@ -41,7 +44,10 @@ func (a *App) Start() error {
} }
a.initHandlers() a.initHandlers()
a.Logger.Info("Vega Poker Bot is running") 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 { func (a *App) Log() *zap.SugaredLogger {
@ -128,23 +134,60 @@ func (a *App) handler(update telego.Update) handler.Handler {
return a.Handlers[handler.Noop] 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 { func (a *App) longPoll() error {
_ = a.Telegram.DeleteWebhook(&telego.DeleteWebhookParams{})
updates, err := a.Telegram.UpdatesViaLongPolling(nil) updates, err := a.Telegram.UpdatesViaLongPolling(nil)
if err != nil { if err != nil {
return err return err
} }
defer a.Telegram.StopLongPolling() defer a.Telegram.StopLongPolling()
for update := range updates { for update := range updates {
go func() { go a.processUpdate(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)
}
}()
} }
return nil 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
}

View File

@ -11,6 +11,8 @@ import (
type Config struct { type Config struct {
PostgresDSN string `default:"" env:"POSTGRES_DSN" yaml:"postgres_dsn" toml:"postgres_dsn"` PostgresDSN string `default:"" env:"POSTGRES_DSN" yaml:"postgres_dsn" toml:"postgres_dsn"`
TelegramToken string `default:"" env:"TELEGRAM_TOKEN" yaml:"telegram_token" toml:"telegram_token"` 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"` Debug bool `default:"false" env:"DEBUG" yaml:"debug" toml:"debug"`
} }

View File

@ -2,35 +2,289 @@ package handler
import ( import (
"encoding/json" "encoding/json"
"errors" "fmt"
"gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/db/model" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/db/model"
"gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/iface" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/iface"
"gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/store" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/store"
"gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/util" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/util"
"gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/integration"
"gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/locale" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/locale"
"github.com/mymmrac/telego" "github.com/mymmrac/telego"
tu "github.com/mymmrac/telego/telegoutil" tu "github.com/mymmrac/telego/telegoutil"
"strings"
) )
type CallbackQueryHandler struct { type CallbackQueryHandler struct {
app iface.App iface.Base
} }
func NewCallbackQueryHandler(app iface.App) Handler { 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 { func (h *CallbackQueryHandler) Handle(wh telego.Update) error {
cq := wh.CallbackQuery 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 { if err != nil {
return err return err
} }
if user != nil && user.ID > 0 { user, err := h.App.DB().ForUser().ByID(chat.UserID)
return h.handleSetupKey(cq, user) if err != nil {
return err
} }
// @todo implement poker polls handling. loc := h.Localizer(user.Language)
return errors.New("not implemented yet") 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 { 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 { 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() result := pl.KeyboardChoice()
if pl.User != user.TelegramID { if pl.User != user.TelegramID {
return nil return nil
@ -79,13 +333,13 @@ func (h *CallbackQueryHandler) handleChooseKeyboard(pl util.Payload, msgID int,
if model.KeyboardType(result.Type) == model.StoryPointsKeyboard { if model.KeyboardType(result.Type) == model.StoryPointsKeyboard {
kbTypeName = "sp_vote_keyboard" kbTypeName = "sp_vote_keyboard"
} }
loc := h.app.Localizer(user.Language) loc := h.Localizer(user.Language)
_, _ = h.app.TG().EditMessageText(&telego.EditMessageTextParams{ _, _ = h.App.TG().EditMessageText(&telego.EditMessageTextParams{
ChatID: tu.ID(user.ChatID), ChatID: tu.ID(user.ChatID),
MessageID: msgID, MessageID: msgID,
Text: loc.Template("chosen_keyboard", map[string]interface{}{"Name": loc.Message(kbTypeName)}), 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), ChatID: tu.ID(user.ChatID),
Text: loc.Message("ask_for_redmine"), Text: loc.Message("ask_for_redmine"),
ParseMode: telego.ModeMarkdown, 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 { 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 { if !answer.Result {
_, _ = h.app.TG().EditMessageText(&telego.EditMessageTextParams{ _, _ = h.App.TG().EditMessageText(&telego.EditMessageTextParams{
ChatID: tu.ID(user.ChatID), ChatID: tu.ID(user.ChatID),
MessageID: msgID, MessageID: msgID,
Text: loc.Message("redmine_will_not_be_configured"), 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), ChatID: tu.ID(user.ChatID),
MessageID: msgID, MessageID: msgID,
Text: loc.Message("please_send_redmine_url"), 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 { 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" message := "redmine_hours_will_not_be_configured"
if answer.Result { if answer.Result {
message = "redmine_hours_will_be_configured" message = "redmine_hours_will_be_configured"
} }
_, _ = h.app.TG().EditMessageText(&telego.EditMessageTextParams{ _, _ = h.App.TG().EditMessageText(&telego.EditMessageTextParams{
ChatID: tu.ID(user.ChatID), ChatID: tu.ID(user.ChatID),
MessageID: msgID, MessageID: msgID,
Text: loc.Message(message), 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), ChatID: tu.ID(user.ChatID),
Text: loc.Message("should_send_poker_to_redmine"), Text: loc.Message("should_send_poker_to_redmine"),
ParseMode: telego.ModeMarkdown, 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 { 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 { if !answer.Result {
_, _ = h.app.TG().EditMessageText(&telego.EditMessageTextParams{ _, _ = h.App.TG().EditMessageText(&telego.EditMessageTextParams{
ChatID: tu.ID(user.ChatID), ChatID: tu.ID(user.ChatID),
MessageID: msgID, MessageID: msgID,
Text: loc.Message("redmine_poker_will_not_be_configured"), 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), ChatID: tu.ID(user.ChatID),
MessageID: msgID, MessageID: msgID,
Text: loc.Message("specify_result_field"), 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 { 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 { if dbChat == nil || dbChat.ID == 0 {
_ = util.SendInternalError(h.app.TG(), user.ChatID, loc) _ = util.SendInternalError(h.App.TG(), user.ChatID, loc)
return err 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 { if savedRedmine == nil || savedRedmine.ID == 0 {
_ = util.SendInternalError(h.app.TG(), user.ChatID, loc) _ = util.SendInternalError(h.App.TG(), user.ChatID, loc)
return err return err
} }
var shouldUpdate bool var shouldUpdate bool
@ -209,7 +463,7 @@ func (h *CallbackQueryHandler) updateRedmineIntegration(rs *store.RedmineSetup,
} }
if shouldUpdate { if shouldUpdate {
savedRedmine.StoreRedmine(savedRS) savedRedmine.StoreRedmine(savedRS)
return h.app.DB().ForIntegration().Save(savedRedmine) return h.App.DB().ForIntegration().Save(savedRedmine)
} }
return nil return nil
} }

View File

@ -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
}

View File

@ -13,21 +13,21 @@ import (
const MaxChatMembers = 32 const MaxChatMembers = 32
type ChatMemberUpdatedHandler struct { type ChatMemberUpdatedHandler struct {
app iface.App iface.Base
} }
func NewChatMemberUpdatedHandler(app iface.App) *ChatMemberUpdatedHandler { 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 { func (h *ChatMemberUpdatedHandler) Handle(wh telego.Update) error {
cm := wh.MyChatMember cm := wh.MyChatMember
if !cm.OldChatMember.MemberIsMember() && cm.OldChatMember.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 { cm.NewChatMember.MemberIsMember() && cm.NewChatMember.MemberUser().ID == h.App.TGProfile().ID {
return h.handleAddToChat(wh.MyChatMember.Chat) return h.handleAddToChat(wh.MyChatMember.Chat)
} }
if cm.OldChatMember.MemberIsMember() && cm.OldChatMember.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 { !cm.NewChatMember.MemberIsMember() && cm.NewChatMember.MemberUser().ID == h.App.TGProfile().ID {
return h.handleRemoveFromChat(wh.MyChatMember.Chat) 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 { func (h *ChatMemberUpdatedHandler) handleAddToChat(tgChat telego.Chat) error {
cr := h.app.DB().ForChat() cr := h.App.DB().ForChat()
chat, err := cr.ByTelegramID(tgChat.ID) chat, err := cr.ByTelegramID(tgChat.ID)
if err != nil { if err != nil {
_ = util.SendInternalError(h.app.TG(), tgChat.ID, nil) _ = util.SendInternalError(h.App.TG(), tgChat.ID, nil)
h.leaveChat(tgChat.ID) h.leaveChat(tgChat.ID)
return err return err
} }
user, err := h.getRegisteredAdmin(tgChat.ID) user, err := h.getRegisteredAdmin(tgChat.ID)
if err != nil { if err != nil {
_ = util.SendInternalError(h.app.TG(), tgChat.ID, nil) _ = util.SendInternalError(h.App.TG(), tgChat.ID, nil)
h.leaveChat(tgChat.ID) h.leaveChat(tgChat.ID)
return err return err
} }
if user == nil || user.ID == 0 { 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), 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, ParseMode: telego.ModeMarkdown,
}) })
h.leaveChat(tgChat.ID) h.leaveChat(tgChat.ID)
return err 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), ChatID: tu.ID(tgChat.ID),
}) })
if err != nil { if err != nil {
_ = util.SendInternalError(h.app.TG(), tgChat.ID, nil) _ = util.SendInternalError(h.App.TG(), tgChat.ID, nil)
h.leaveChat(tgChat.ID) h.leaveChat(tgChat.ID)
return err return err
} }
if *totalMembers > MaxChatMembers { if *totalMembers > MaxChatMembers {
_, err = h.app.TG().SendMessage(&telego.SendMessageParams{ _, err = h.App.TG().SendMessage(&telego.SendMessageParams{
ChatID: tu.ID(tgChat.ID), ChatID: tu.ID(tgChat.ID),
Text: loc.Template("too_many_members_in_the_group", map[string]interface{}{"Limit": MaxChatMembers}), Text: loc.Template("too_many_members_in_the_group", map[string]interface{}{"Limit": MaxChatMembers}),
ParseMode: telego.ModeMarkdown, ParseMode: telego.ModeMarkdown,
@ -87,27 +87,27 @@ func (h *ChatMemberUpdatedHandler) handleAddToChat(tgChat telego.Chat) error {
} }
err := cr.Save(chat) err := cr.Save(chat)
if err != nil { if err != nil {
_ = util.SendInternalError(h.app.TG(), tgChat.ID, loc) _ = util.SendInternalError(h.App.TG(), tgChat.ID, loc)
h.leaveChat(tgChat.ID) h.leaveChat(tgChat.ID)
return err return err
} }
} else { } else {
chat.UserID = user.ID chat.UserID = user.ID
err := h.app.DB().ForIntegration().DeleteForChat(chat.ID) err := h.App.DB().ForIntegration().DeleteForChat(chat.ID)
if err != nil { if err != nil {
_ = util.SendInternalError(h.app.TG(), tgChat.ID, loc) _ = util.SendInternalError(h.App.TG(), tgChat.ID, loc)
h.leaveChat(tgChat.ID) h.leaveChat(tgChat.ID)
return err return err
} }
err = cr.Save(chat) err = cr.Save(chat)
if err != nil { if err != nil {
_ = util.SendInternalError(h.app.TG(), tgChat.ID, loc) _ = util.SendInternalError(h.App.TG(), tgChat.ID, loc)
h.leaveChat(tgChat.ID) h.leaveChat(tgChat.ID)
return err return err
} }
} }
_, _ = h.app.TG().SendMessage(&telego.SendMessageParams{ _, _ = h.App.TG().SendMessage(&telego.SendMessageParams{
ChatID: tu.ID(user.ChatID), ChatID: tu.ID(user.ChatID),
Text: loc.Template("bot_was_added", map[string]interface{}{"Name": tgChat.Title}), Text: loc.Template("bot_was_added", map[string]interface{}{"Name": tgChat.Title}),
ParseMode: telego.ModeMarkdown, ParseMode: telego.ModeMarkdown,
@ -117,17 +117,17 @@ func (h *ChatMemberUpdatedHandler) handleAddToChat(tgChat telego.Chat) error {
} }
func (h *ChatMemberUpdatedHandler) handleRemoveFromChat(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) chat, err := cr.ByTelegramID(tgChat.ID)
if err != nil { if err != nil {
return err return err
} }
if chat != nil && chat.ID > 0 { 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 { 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), 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}), "bot_was_removed_from_group", map[string]interface{}{"Name": tgChat.Title}),
ParseMode: telego.ModeMarkdown, 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 { 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), ChatID: tu.ID(user.ChatID),
Text: loc.Template("choose_keyboard", map[string]interface{}{"Name": chat.Title}), Text: loc.Template("choose_keyboard", map[string]interface{}{"Name": chat.Title}),
ParseMode: telego.ModeMarkdown, 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) { 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), ChatID: tu.ID(chatID),
}) })
if err != nil { if err != nil {
@ -175,7 +175,7 @@ func (h *ChatMemberUpdatedHandler) getRegisteredAdmin(chatID int64) (*model.User
for i, admin := range admins { for i, admin := range admins {
adminIDs[i] = admin.MemberUser().ID adminIDs[i] = admin.MemberUser().ID
} }
dbAdmins, err := h.app.DB().ForUser().ByTelegramIDs(adminIDs) dbAdmins, err := h.App.DB().ForUser().ByTelegramIDs(adminIDs)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@ -186,7 +186,7 @@ func (h *ChatMemberUpdatedHandler) getRegisteredAdmin(chatID int64) (*model.User
} }
func (h *ChatMemberUpdatedHandler) leaveChat(chatID int64) { func (h *ChatMemberUpdatedHandler) leaveChat(chatID int64) {
_ = h.app.TG().LeaveChat(&telego.LeaveChatParams{ _ = h.App.TG().LeaveChat(&telego.LeaveChatParams{
ChatID: tu.ID(chatID), ChatID: tu.ID(chatID),
}) })
} }

View File

@ -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
}

View File

@ -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",
},
},
},
}
}

View File

@ -1,8 +1,7 @@
package handler package handler
import ( import (
"encoding/json" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/group"
"gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/chat"
"gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/iface" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/iface"
"gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/store" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/store"
"gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/util" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/util"
@ -11,39 +10,34 @@ import (
) )
type MessageHandler struct { type MessageHandler struct {
app iface.App iface.Base
} }
func NewMessageHandler(app iface.App) Handler { 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 { func (h *MessageHandler) Handle(wh telego.Update) error {
if wh.Message.From != nil && if wh.Message.From == nil {
wh.Message.Chat.Type == telego.ChatTypePrivate { return nil
if util.MatchCommand("poll", wh.Message) { }
return chat.NewPoll(h.app, wh.Message.From.ID, wh.Message.Chat.ID).Handle(wh) if wh.Message.Chat.Type == telego.ChatTypePrivate {
}
if util.MatchCommand("start", wh.Message) { 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) { 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) setup, found := store.RedmineSetups.Get(wh.Message.Chat.ID)
if found { 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. if util.MatchCommand("poll", wh.Message) {
h.app.Log().Debugf("New Message: %s", func(msg *telego.Message) string { return group.NewPoll(h.App, wh.Message.From.ID, wh.Message.Chat.ID).Handle(wh)
data, _ := json.Marshal(msg) }
return string(data)
}(wh.Message))
return nil return wizard.NewUnknownCommand(h.App, wh.Message.From.ID, wh.Message.Chat.ID).Handle(wh)
} }

View File

@ -6,8 +6,8 @@ import (
) )
func init() { func init() {
cache, err := otter.MustBuilder[int64, PollState](10_000). cache, err := otter.MustBuilder[int, PollState](10_000).
Cost(func(key int64, value PollState) uint32 { Cost(func(key int, value PollState) uint32 {
return 1 return 1
}). }).
WithTTL(time.Hour * 24 * 7). WithTTL(time.Hour * 24 * 7).

View File

@ -3,24 +3,43 @@ package store
import ( import (
"gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/util" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/handler/util"
"github.com/maypok86/otter" "github.com/maypok86/otter"
"math"
) )
type MemberVote struct { type MemberVote struct {
util.Member util.Member
Vote float32 Vote float64
} }
type PollResult struct { type PollResult struct {
Max float32 Max float64
Min float32 Min float64
Avg float32 Avg float64
Halved float32 RoundHalf float64
} }
type PollState struct { type PollState struct {
Members []util.Member Initiator string
Votes []MemberVote Subject string
Result PollResult 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]

View File

@ -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(),
},
},
},
}
}

View File

@ -10,6 +10,8 @@ const (
PayloadActionYesNo = iota PayloadActionYesNo = iota
PayloadActionVote PayloadActionVote
PayloadActionChooseKeyboard PayloadActionChooseKeyboard
PayloadActionVoteFinish
PayloadActionVoteSendRedmine
) )
type QuestionType uint8 type QuestionType uint8
@ -21,10 +23,11 @@ const (
) )
type Payload struct { type Payload struct {
User int64 `json:"u,omitempty"` User int64 `json:"u,omitempty"`
Chat int64 `json:"c,omitempty"` Chat int64 `json:"c,omitempty"`
Action PayloadAction `json:"a"` Message int `json:"m,omitempty"`
Data json.RawMessage `json:"d,omitempty"` Action PayloadAction `json:"a"`
Data json.RawMessage `json:"d,omitempty"`
} }
func (p Payload) String() string { func (p Payload) String() string {
@ -66,7 +69,7 @@ type KBChooserData struct {
} }
type Vote struct { type Vote struct {
Vote float32 `json:"v"` Vote float64 `json:"v"`
} }
type Answer struct { 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{ return &Payload{
Action: PayloadActionVote, Action: PayloadActionVote,
Chat: chatID, 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 { func NewRedmineQuestionPayload(userID int64, chatID int64, isYes bool) *Payload {
return &Payload{ return &Payload{
Action: PayloadActionYesNo, 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 { func marshal(val interface{}) json.RawMessage {
data, _ := json.Marshal(val) data, _ := json.Marshal(val)
return data return data

View File

@ -5,12 +5,13 @@ import (
"gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/integration/iface" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/integration/iface"
"gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/integration/null" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/integration/null"
"gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/integration/redmine" "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 { switch dbModel.Type {
case model.RedmineIntegration: case model.RedmineIntegration:
return redmine.New(dbModel.Params) return redmine.New(dbModel.Params, log)
default: default:
return null.New() return null.New()
} }

View File

@ -1,5 +1,6 @@
package iface package iface
type Integration interface { type Integration interface {
GetTaskInfoText(input string) string GetTaskInfo(input string) (int, string)
PushVoteResult(id int, result float64) error
} }

View File

@ -8,6 +8,10 @@ func New() iface.Integration {
return &Null{} return &Null{}
} }
func (n *Null) GetTaskInfoText() string { func (n *Null) GetTaskInfo(string) (int, string) {
return "" return 0, ""
}
func (n *Null) PushVoteResult(id int, result float64) error {
return nil
} }

View File

@ -3,6 +3,8 @@ package api
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"go.uber.org/zap"
"io" "io"
"net/http" "net/http"
"strconv" "strconv"
@ -14,19 +16,21 @@ type Client struct {
URL string URL string
Key string Key string
client *http.Client client *http.Client
log *zap.SugaredLogger
} }
func New(url, key string) *Client { func New(url, key string, log *zap.SugaredLogger) *Client {
return &Client{ return &Client{
URL: strings.TrimSuffix(url, "/"), URL: strings.TrimSuffix(url, "/"),
Key: key, Key: key,
client: &http.Client{Timeout: time.Second * 2}, 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 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 { if err != nil {
return nil, err return nil, err
} }
@ -36,31 +40,59 @@ func (c *Client) Issue(id uint64) (*Issue, error) {
return resp.Issue, nil 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) req, err := http.NewRequest(method, c.URL+path, body)
if err != nil { if err != nil {
return nil, err return nil, err
} }
req.Header.Set("X-Redmine-API-Key", c.Key) req.Header.Set("X-Redmine-API-Key", c.Key)
for name, value := range headers {
req.Header.Set(name, value)
}
return c.client.Do(req) 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 var buf io.Reader
if body != nil { if body != nil {
data, err := json.Marshal(body) data, err := json.Marshal(body)
if err != nil { if err != nil {
return err return 0, err
} }
buf = bytes.NewBuffer(data) 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 "<nil>"
}())
resp, err := c.sendRequest(method, path, map[string]string{
"Content-Type": "application/json",
}, buf)
if err != nil { if err != nil {
return err return 0, err
} }
defer func() { _ = resp.Body.Close() }() 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
} }

View File

@ -1,12 +1,14 @@
package api package api
import ( import (
"encoding/json"
"strconv"
"strings" "strings"
"time" "time"
) )
type ErrorResponse struct { type ErrorResponse struct {
Errors []string `json:"errors"` Errors []string `json:"errors,omitempty"`
} }
func (e ErrorResponse) Error() string { func (e ErrorResponse) Error() string {
@ -34,26 +36,49 @@ type CustomField struct {
} }
type Issue struct { type Issue struct {
ID int `json:"id"` ID int `json:"id,omitempty"`
Project NameWithID `json:"project"` Project *NameWithID `json:"project,omitempty"`
Tracker NameWithID `json:"tracker"` Tracker *NameWithID `json:"tracker,omitempty"`
Status NameWithID `json:"status"` Status *NameWithID `json:"status,omitempty"`
Priority NameWithID `json:"priority"` Priority *NameWithID `json:"priority,omitempty"`
Author NameWithID `json:"author"` Author *NameWithID `json:"author,omitempty"`
AssignedTo NameWithID `json:"assigned_to"` AssignedTo *NameWithID `json:"assigned_to,omitempty"`
Parent NameWithID `json:"parent"` Parent *NameWithID `json:"parent,omitempty"`
Subject string `json:"subject"` Subject string `json:"subject,omitempty"`
Description string `json:"description"` Description string `json:"description,omitempty"`
StartDate interface{} `json:"start_date"` StartDate interface{} `json:"start_date,omitempty"`
DueDate interface{} `json:"due_date"` DueDate interface{} `json:"due_date,omitempty"`
DoneRatio int `json:"done_ratio"` DoneRatio int `json:"done_ratio,omitempty"`
IsPrivate bool `json:"is_private"` IsPrivate bool `json:"is_private,omitempty"`
EstimatedHours interface{} `json:"estimated_hours"` EstimatedHours Hours `json:"estimated_hours,omitempty"`
TotalEstimatedHours interface{} `json:"total_estimated_hours"` TotalEstimatedHours Hours `json:"total_estimated_hours,omitempty"`
SpentHours float64 `json:"spent_hours"` SpentHours Hours `json:"spent_hours,omitempty"`
TotalSpentHours float64 `json:"total_spent_hours"` TotalSpentHours Hours `json:"total_spent_hours,omitempty"`
CustomFields []CustomField `json:"custom_fields"` CustomFields []CustomField `json:"custom_fields,omitempty"`
CreatedOn time.Time `json:"created_on"` CreatedOn *time.Time `json:"created_on,omitempty"`
UpdatedOn time.Time `json:"updated_on"` UpdatedOn *time.Time `json:"updated_on,omitempty"`
ClosedOn interface{} `json:"closed_on"` 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
} }

View File

@ -5,6 +5,7 @@ import (
"gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/integration/iface" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/integration/iface"
"gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/integration/null" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/integration/null"
"gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/integration/redmine/api" "gitea.neur0tx.site/Neur0toxine/vegapokerbot/internal/integration/redmine/api"
"go.uber.org/zap"
"regexp" "regexp"
"strconv" "strconv"
"strings" "strings"
@ -15,10 +16,12 @@ var issueNumberMatcher = regexp.MustCompile(`(?m)\/issues\/(\d+)`)
var sprintNames = []string{"sprint", "спринт"} var sprintNames = []string{"sprint", "спринт"}
type Redmine struct { 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"] uri, ok := params["url"]
if !ok { if !ok {
return null.New() return null.New()
@ -35,28 +38,57 @@ func New(params map[string]interface{}) iface.Integration {
if !ok { if !ok {
return null.New() 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) taskNumber := r.getTaskNumber(input)
if taskNumber == 0 { if taskNumber == 0 {
return "" r.log.Debugf("[Redmine] Cannot extract task number from %s", input)
return 0, ""
} }
task, err := r.api.Issue(taskNumber) task, err := r.api.Issue(taskNumber)
if err != nil { if err != nil {
return "" r.log.Errorf("[Redmine] Cannot get Redmine issue %d: %s", taskNumber, err)
return 0, ""
} }
sprint := r.getSprint(task.CustomFields) sprint := r.getSprint(task.CustomFields)
if sprint != "" { if sprint != "" {
sprint = fmt.Sprintf(" - **%s**", sprint) sprint = fmt.Sprintf(" - _%s_", sprint)
} }
return fmt.Sprintf("[(%s) %s: %s](%s/issues/%d)%s", 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) 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 { func (r *Redmine) PushVoteResult(id int, result float64) error {
num, err := strconv.ParseUint(input, 10, 64) 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 { if err == nil && num > 0 {
return num return num
} }
@ -64,7 +96,7 @@ func (r *Redmine) getTaskNumber(input string) uint64 {
if len(matches) < 2 { if len(matches) < 2 {
return 0 return 0
} }
number, err := strconv.ParseUint(matches[1], 10, 64) number, err := strconv.Atoi(matches[1])
if err != nil { if err != nil {
return 0 return 0
} }

View File

@ -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." 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" yes: "✔️ Yes"
no: "✖️ No" 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."

View File

@ -11,6 +11,7 @@ unknown_command: "❔ Неизвестная команда. Используй
help_output: "📄 Доступные команды:\n\n• /start - регистрация в боте;\n• /poll@vegapokerbot <task> - запускает poker в группе, работает только в подключенных группах;\n• /help - выводит эту справку." help_output: "📄 Доступные команды:\n\n• /start - регистрация в боте;\n• /poll@vegapokerbot <task> - запускает poker в группе, работает только в подключенных группах;\n• /help - выводит эту справку."
choose_at_least_one: "❌ Вы должны выбрать хотя бы одного участника." choose_at_least_one: "❌ Вы должны выбрать хотя бы одного участника."
ask_for_redmine: "📕 Настроить интеграцию с Redmine?" ask_for_redmine: "📕 Настроить интеграцию с Redmine?"
send_result_redmine: "📕 Отправить результат в Redmine"
redmine_will_not_be_configured: "👌 Интеграция с Redmine не будет настроена. Вы не сможете отправлять результаты poker в Redmine или получать информацию о задачах." redmine_will_not_be_configured: "👌 Интеграция с Redmine не будет настроена. Вы не сможете отправлять результаты poker в Redmine или получать информацию о задачах."
redmine_hours_will_not_be_configured: "👌 Оценка временных затрат не будет передаваться в Redmine." redmine_hours_will_not_be_configured: "👌 Оценка временных затрат не будет передаваться в Redmine."
redmine_hours_will_be_configured: "👌 Оценка временных затрат будет передаваться в Redmine." redmine_hours_will_be_configured: "👌 Оценка временных затрат будет передаваться в Redmine."
@ -28,3 +29,15 @@ should_send_estimated_hours_redmine: "🕑 Передавать в поле *О
setup_done: "✔️ Готово, чат подключен к боту!\nИспользуйте команду /poll@vegapokerbot в подключенном чате для запуска голосования." setup_done: "✔️ Готово, чат подключен к боту!\nИспользуйте команду /poll@vegapokerbot в подключенном чате для запуска голосования."
yes: "✔️ Да" yes: "✔️ Да"
no: "✖️ Нет" 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: "Не удалось найти это голосование. Запустите его заново если оно вам нужно."

View File

@ -4,7 +4,7 @@ import "github.com/nicksnyder/go-i18n/v2/i18n"
type Localizer interface { type Localizer interface {
Message(string) string Message(string) string
Template(string, map[string]interface{}) string Template(string, interface{}) string
} }
type localizer struct { type localizer struct {
@ -15,7 +15,7 @@ func (l *localizer) Message(str string) string {
return l.Template(str, nil) 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{ return l.loc.MustLocalize(&i18n.LocalizeConfig{
MessageID: str, MessageID: str,
TemplateData: tpl, TemplateData: tpl,