From 730bda35fe3972b77b81a749c8e2c7b7947908ee Mon Sep 17 00:00:00 2001 From: Yiyang Kang Date: Wed, 7 Dec 2022 11:01:21 +0800 Subject: [PATCH] feat: /xr command for currency exchange rates --- bot.go | 161 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ main.go | 24 +++++++++ 2 files changed, 185 insertions(+) diff --git a/bot.go b/bot.go index ac7d2d5..5f68502 100644 --- a/bot.go +++ b/bot.go @@ -4,10 +4,14 @@ import ( "fmt" "html" "math" + "regexp" + "strconv" "strings" "text/tabwriter" "time" + "git.gensokyo.cafe/kkyy/mycurrencynet" + "github.com/dustin/go-humanize" "github.com/go-errors/errors" "github.com/samber/lo" tele "gopkg.in/telebot.v3" @@ -49,6 +53,7 @@ func initBot() (*tele.Bot, error) { b.Handle("/me", handleUserInfoCmd) b.Handle("/chat", handleChatInfoCmd) b.Handle("/year_progress", handleYearProgressCmd) + b.Handle("/xr", handleExchangeRateCmd) b.Handle(tele.OnText, handleGeneralMessage) b.Handle(tele.OnSticker, handleGeneralMessage) @@ -292,3 +297,159 @@ func handleDigCmd(c tele.Context) error { } return c.Reply(strings.Join(replyText, ""), &tele.SendOptions{ParseMode: tele.ModeHTML}, tele.Silent) } + +func handleExchangeRateCmd(c tele.Context) error { + msg := c.Message() + if msg == nil { + return nil + } + + if msg.Payload == "" { + return c.Reply("Usage: `/xr [ ...] [to] `", + &tele.SendOptions{ParseMode: tele.ModeMarkdown}, + tele.Silent, + ) + } + + from, to, err := parseXrRequest(msg.Payload) + if err != nil { + return c.Reply(err.Error(), tele.Silent) + } + codes := append([]string{to.Code}, lo.Map(from, func(i xrCurrency, _ int) string { return i.Code })...) + currencies, err := exchangeRates.Get(codes...) + if err != nil { + return c.Reply(err.Error(), tele.Silent) + } + toCur := currencies[0] + + // format reply + var replyLines []string + for i, fromItem := range from { + fromCur := currencies[i+1] + replyLines = append(replyLines, fmtXrPair(fromCur, toCur, fromItem.Amount)) + } + + return c.Reply(strings.Join(replyLines, "\n"), &tele.SendOptions{ParseMode: tele.ModeMarkdown}, tele.Silent) +} + +type xrCurrency struct { + Code string + Amount float64 +} + +func parseXrRequest(input string) (from []xrCurrency, to xrCurrency, err error) { + args := strings.Fields(input) + + var tmpTo *xrCurrency = nil + fillFrom := true + + c := xrCurrency{Amount: -1} + for _, arg := range args { + arg = strings.ToUpper(arg) + + switch { + case arg == "TO": + fillFrom = false + continue + + case fillFrom: + num, sym, tErr := parseXrToken(arg) + if tErr != nil { + err = tErr + return + } + if num != -1 { + if c.Amount != -1 { + err = errors.New("invalid input") + return + } + c.Amount = num + } + if c.Amount == -1 { + c.Amount = 1 + } + + if sym != "" { + c.Code = sym + from = append(from, c) + c = xrCurrency{Amount: -1} + } + + case !fillFrom: + if tmpTo != nil { + err = errors.New("invalid input") + return + } + tmpTo = &xrCurrency{Code: arg} + + default: + err = errors.New("invalid input") + return + } + } + + if len(from) < 1 || len(from) > 10 { + err = errors.New("invalid input") + return + } + + if fillFrom { + if len(from) < 2 { + err = errors.New("invalid input") + return + } + tmpTo = &from[len(from)-1] + from = from[:len(from)-1] + } + + if tmpTo == nil { + err = errors.New("invalid input") + return + } + to = *tmpTo + + return +} + +var xrTokenRe = regexp.MustCompile(`^([0-9.]+)?([A-Z]{3,})?$`) + +func parseXrToken(token string) (num float64, sym string, err error) { + num = -1 + matches := xrTokenRe.FindStringSubmatch(token) + if len(matches) != 3 { + err = errors.New("invalid input") + return + } + + if matches[1] != "" { + num, err = strconv.ParseFloat(matches[1], 64) + if err != nil { + return + } + if num <= 0 { + err = errors.New("invalid input: amount must be positive") + return + } + } + + sym = matches[2] + if num == 0 && sym == "" { + err = errors.New("invalid input") + } + return +} + +func fmtXrPair(from, to *mycurrencynet.Currency, amount float64) string { + rate := from.To(to) * amount + fromInfo := fmt.Sprintf("%s (%s)", from.Name, from.Code) + toInfo := fmt.Sprintf("%s (%s)", to.Name, to.Code) + + return fmt.Sprintf("*%s* %s\n= *%s* %s\n", fmtXrFloat(amount), fromInfo, fmtXrFloat(rate), toInfo) +} + +func fmtXrFloat(num float64) string { + if num < 100 { + return fmt.Sprintf("%.4g", num) + } + return humanize.CommafWithDigits(num, 2) +} diff --git a/main.go b/main.go index ca67af9..a3c87c3 100644 --- a/main.go +++ b/main.go @@ -4,7 +4,9 @@ import ( "os" "os/signal" "syscall" + "time" + "git.gensokyo.cafe/kkyy/mycurrencynet" "go.uber.org/zap" tele "gopkg.in/telebot.v3" @@ -22,6 +24,25 @@ func initLogger() { logger = l.Sugar() } +var exchangeRates = mycurrencynet.New() + +func initExchangeRates() { + if err := exchangeRates.Update(); err != nil { + logger.Panicw("Failed to update exchange rates", "err", err) + } + logger.Info("Exchange rates updated") + + go exchangeRates.UpdateEvery( + time.Hour, + func(err error) { + logger.Errorw("Failed to update exchange rates", "err", err) + }, + func() { + logger.Info("Exchange rates updated") + }, + ) +} + func runBot() { logger.Info("Bot initializing...") bot, err := initBot() @@ -37,6 +58,7 @@ func runBot() { {Text: "traffic", Description: "Show traffic usage."}, {Text: "dig", Description: "Diggy diggy dig."}, {Text: "year_progress", Description: "Time doesn't wait."}, + {Text: "xr", Description: "Currency exchange rates"}, }); err != nil { logger.Fatalw("Failed to announce commands", "err", err) } @@ -45,6 +67,8 @@ func runBot() { botFinCh := utils.WaitFor(bot.Start) logger.Infow("Bot started", "username", bot.Me.Username) + go initExchangeRates() + // listen for shutdown signal sigCh := make(chan os.Signal, 1) signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM)