diff --git a/internal/configs/binance/asset.go b/internal/configs/binance/asset.go new file mode 100644 index 0000000..60208b6 --- /dev/null +++ b/internal/configs/binance/asset.go @@ -0,0 +1,21 @@ +package binance + +const ( + AssetUsdt = "USDT" + AssetUsdc = "USDC" + AssetFdusd = "FDUSD" + AssetBtc = "BTC" + AssetBnb = "BNB" + AssetEth = "ETH" + AssetXrp = "XRP" +) + +var SymbolPrefixList = []string{"1000"} +var SymbolSuffixMap = map[string]string{ + "USDT": "", + "USDC": "c", +} + +var Future2SpotSymbolMap = map[string]string{ + "LUNA2USDT": "LUNAUSDT", +} diff --git a/internal/configs/config.go b/internal/configs/config.go new file mode 100644 index 0000000..71a3db4 --- /dev/null +++ b/internal/configs/config.go @@ -0,0 +1,5 @@ +package configs + +var ( + LogEnv = true +) diff --git a/internal/configs/key/env.go b/internal/configs/key/env.go new file mode 100644 index 0000000..d9bd0be --- /dev/null +++ b/internal/configs/key/env.go @@ -0,0 +1,7 @@ +package key + +const ( + LogEnv = "LOG_ENV" + + TelegramToken = "TELEGRAM_TOKEN" +) diff --git a/internal/configs/strategy/market.go b/internal/configs/strategy/market.go new file mode 100644 index 0000000..f5af1c5 --- /dev/null +++ b/internal/configs/strategy/market.go @@ -0,0 +1,20 @@ +package strategy + +const ( + symbolBTC = "BTCUSDT" + symbolETH = "ETHUSDT" + symbolBNB = "BNBUSDT" + symbolTON = "TONUSDT" + symbolXRP = "XRPUSDT" +) + +var ( + TopPriceSymbols = []string{symbolBTC, symbolETH, symbolBNB, symbolTON, symbolXRP} +) + +const ( + FundRateBuyThreshold = -0.001 + FundRateSellThreshold = 0.00077 + + LiquidInfPrice = 1000_000_000 +) diff --git a/internal/configs/tele/sticker.go b/internal/configs/tele/sticker.go new file mode 100644 index 0000000..d6fce43 --- /dev/null +++ b/internal/configs/tele/sticker.go @@ -0,0 +1,17 @@ +package tele + +const ( + StickerSet = "verichains" + + StickerTop = "AgADfQwAAinYuVQ" +) + +var Token2StickerIdxMap = map[string]int{ + "BNB": 3, + "TON": 7, +} + +var Sticker2TokenMap = map[string]string{ + "AgAD-BQAAtQesFQ": "TON", + "AgADxhEAAoNAIFQ": "BNB", +} diff --git a/internal/data/imarket.go b/internal/data/imarket.go new file mode 100644 index 0000000..52519c3 --- /dev/null +++ b/internal/data/imarket.go @@ -0,0 +1,8 @@ +package data + +type IMarket interface { + GetFuturePrice(symbol string) (float64, float64, int64, bool) + GetAllFundRate() (map[string]float64, map[string]int64) + + GetSpotPrice(symbol string) (float64, bool) +} diff --git a/internal/data/market/future_price.go b/internal/data/market/future_price.go new file mode 100644 index 0000000..46851a0 --- /dev/null +++ b/internal/data/market/future_price.go @@ -0,0 +1,50 @@ +package market + +import ( + "github.com/adshao/go-binance/v2/futures" + "github.com/rs/zerolog/log" + "strconv" +) + +func (ms MarketData) GetFuturePrice(symbol string) (float64, float64, int64, bool) { + p, ok := ms.futureMarkPrice[symbol] + if !ok { + return 0, 0, 0, false + } + return p, ms.futureFundingRate[symbol], ms.futureNextFundingTime[symbol], true +} + +func (ms MarketData) StartFutureWsMarkPrice() error { + _, _, err := futures.WsAllMarkPriceServe(ms.futureWsMarkPriceHandler, ms.futureWsErrHandler) + if err != nil { + return err + } + return nil +} + +func (ms MarketData) futureWsMarkPriceHandler(event futures.WsAllMarkPriceEvent) { + for _, priceEvent := range event { + price, err := strconv.ParseFloat(priceEvent.MarkPrice, 64) + if err != nil { + continue + } + + fundingRate, err := strconv.ParseFloat(priceEvent.FundingRate, 64) + if err != nil { + continue + } + + ms.futureMarkPrice[priceEvent.Symbol] = price + ms.futureFundingRate[priceEvent.Symbol] = fundingRate + ms.futureNextFundingTime[priceEvent.Symbol] = priceEvent.NextFundingTime + } +} + +func (ms MarketData) futureWsErrHandler(err error) { + log.Debug().Err(err).Msg("Ws Error. Restart socket") + _ = ms.StartFutureWsMarkPrice() +} + +func (ms MarketData) GetAllFundRate() (map[string]float64, map[string]int64) { + return ms.futureFundingRate, ms.futureNextFundingTime +} diff --git a/internal/data/market/main.go b/internal/data/market/main.go new file mode 100644 index 0000000..19bec1a --- /dev/null +++ b/internal/data/market/main.go @@ -0,0 +1,25 @@ +package market + +import "github.com/rs/zerolog/log" + +type MarketData struct { + futureMarkPrice map[string]float64 + futureFundingRate map[string]float64 + futureNextFundingTime map[string]int64 + + spotPrice map[string]float64 +} + +func NewMarketData() *MarketData { + log.Info().Msg("Start market service") + ms := &MarketData{ + futureMarkPrice: make(map[string]float64), + futureFundingRate: make(map[string]float64), + futureNextFundingTime: make(map[string]int64), + + spotPrice: make(map[string]float64), + } + _ = ms.StartFutureWsMarkPrice() + _ = ms.StartSpotWsMarkPrice() + return ms +} diff --git a/internal/data/market/spot_price.go b/internal/data/market/spot_price.go new file mode 100644 index 0000000..9cacca6 --- /dev/null +++ b/internal/data/market/spot_price.go @@ -0,0 +1,36 @@ +package market + +import ( + "github.com/adshao/go-binance/v2" + "github.com/rs/zerolog/log" + "strconv" +) + +func (ms MarketData) GetSpotPrice(symbol string) (float64, bool) { + p, ok := ms.spotPrice[symbol] + return p, ok +} + +func (ms MarketData) StartSpotWsMarkPrice() error { + _, _, err := binance.WsAllMarketsStatServe(ms.spotWsAllMarketsStatHandler, ms.spotWsErrHandler) //.WsAllMarkPriceServe(ms.futureWsMarkPriceHandler, ms.futureWsErrHandler) + + if err != nil { + return err + } + return nil +} + +func (ms MarketData) spotWsAllMarketsStatHandler(event binance.WsAllMarketsStatEvent) { + for _, priceEvent := range event { + price, err := strconv.ParseFloat(priceEvent.LastPrice, 64) + if err != nil { + continue + } + ms.spotPrice[priceEvent.Symbol] = price + } +} + +func (ms MarketData) spotWsErrHandler(err error) { + log.Debug().Err(err).Msg("Spot Ws Error. Restart socket") + _ = ms.StartSpotWsMarkPrice() +} diff --git a/internal/data/repo.go b/internal/data/repo.go new file mode 100644 index 0000000..4d492f8 --- /dev/null +++ b/internal/data/repo.go @@ -0,0 +1,15 @@ +package data + +import "sync" + +var ( + Market IMarket + once sync.Once +) + +func NewRepository(market IMarket) { + once.Do( + func() { + Market = market + }) +} diff --git a/internal/deps/data.go b/internal/deps/data.go new file mode 100644 index 0000000..df89a8b --- /dev/null +++ b/internal/deps/data.go @@ -0,0 +1,12 @@ +package deps + +import ( + "me.thuanle/bbot/internal/data" + market2 "me.thuanle/bbot/internal/data/market" +) + +func InitData() { + marketData := market2.NewMarketData() + + data.NewRepository(marketData) +} diff --git a/internal/helper/binancex/symbol.go b/internal/helper/binancex/symbol.go new file mode 100644 index 0000000..62d5f0b --- /dev/null +++ b/internal/helper/binancex/symbol.go @@ -0,0 +1,95 @@ +package binancex + +import ( + "me.thuanle/bbot/internal/configs/binance" + "me.thuanle/bbot/internal/data" + "me.thuanle/bbot/internal/utils/stringx" + "strings" +) + +func Symbol2Token(sym string) string { + token := strings.ToUpper(sym) + + for _, prefix := range binance.SymbolPrefixList { + token, _ = strings.CutPrefix(token, prefix) + } + for suffix, abbr := range binance.SymbolSuffixMap { + var f bool + token, f = stringx.ReplaceSuffix(token, suffix, abbr) + if f { + break + } + } + return token +} + +var ( + checkingPrefixList = append(binance.SymbolPrefixList, "") +) + +func testSym(sym string) bool { + _, _, _, test := data.Market.GetFuturePrice(sym) + return test +} + +func Token2Symbols(token string) []string { + var syms []string + if !stringx.IsAlphaNumeric(token) { + return syms + } + + token = strings.ToUpper(token) + + for _, prefix := range checkingPrefixList { + prefix = strings.ToUpper(prefix) + + s := prefix + token + + //no suffix + if testSym(s) { + syms = append(syms, s) + continue + } + + for suffix, abbr := range binance.SymbolSuffixMap { + suffix = strings.ToUpper(suffix) + abbr = strings.ToUpper(abbr) + + //suffix + sym := s + suffix + if testSym(sym) { + syms = append(syms, sym) + continue + } + + //suffix abbr + symAbr, found := stringx.ReplaceSuffix(s, abbr, suffix) + if found && testSym(symAbr) { + syms = append(syms, symAbr) + continue + } + } + } + return syms +} + +func IsToken(s string) bool { + return len(Token2Symbols(s)) > 0 +} + +// Future2SpotSymbol convert future symbol to spot symbol +func Future2SpotSymbol(sym string) string { + for _, prefix := range binance.SymbolPrefixList { + var f bool + sym, f = strings.CutPrefix(sym, prefix) + if f { + break + } + } + + spotSym, ok := binance.Future2SpotSymbolMap[sym] + if !ok { + return sym + } + return spotSym +} diff --git a/internal/load.go b/internal/load.go new file mode 100644 index 0000000..eb174fb --- /dev/null +++ b/internal/load.go @@ -0,0 +1,27 @@ +package internal + +import ( + "fmt" + "github.com/joho/godotenv" + "github.com/rs/zerolog" + "github.com/rs/zerolog/log" + "me.thuanle/bbot/internal/configs" + "me.thuanle/bbot/internal/configs/key" + "me.thuanle/bbot/internal/utils/envx" +) + +func Startup() { + prepareEnv() + + zerolog.TimeFieldFormat = zerolog.TimeFormatUnix + + log.Info().Msg("Starting up...") +} + +func prepareEnv() { + if err := godotenv.Load(); err != nil { + fmt.Println("Error loading .env file", err) + } + + configs.LogEnv, _ = envx.GetEnvBool(key.LogEnv) +} diff --git a/internal/services/controllers/market.go b/internal/services/controllers/market.go new file mode 100644 index 0000000..bf30653 --- /dev/null +++ b/internal/services/controllers/market.go @@ -0,0 +1,52 @@ +package controllers + +import ( + "me.thuanle/bbot/internal/configs/strategy" + "me.thuanle/bbot/internal/data" + "sort" +) + +// GetTopPrices returns the top symbol, price and rate +func GetTopPrices() ([]string, []float64, []float64) { + n := len(strategy.TopPriceSymbols) + topSym := make([]string, n) + topPrice := make([]float64, n) + topRate := make([]float64, n) + + for i, sym := range strategy.TopPriceSymbols { + price, rate, _, ok := data.Market.GetFuturePrice(sym) + if !ok { + continue + } + topSym[i] = sym + topPrice[i] = price + topRate[i] = rate + + } + return topSym, topPrice, topRate +} + +// GetTopFundingFee returns the top funding fee: symbol, rate and cd time +func GetTopFundingFee() ([]string, []float64, []int64) { + allRates, allCd := data.Market.GetAllFundRate() + var resSym []string + for sym, rate := range allRates { + if strategy.FundRateBuyThreshold < rate && rate < strategy.FundRateSellThreshold { + continue + } + + resSym = append(resSym, sym) + } + + sort.Slice(resSym, func(i, j int) bool { + return allRates[resSym[i]] < allRates[resSym[j]] + }) + + resRate := make([]float64, len(resSym)) + resCd := make([]int64, len(resSym)) + for i, sym := range resSym { + resRate[i] = allRates[sym] + resCd[i] = allCd[sym] + } + return resSym, resRate, resCd +} diff --git a/internal/services/tele/chat/helper/wrap.go b/internal/services/tele/chat/helper/wrap.go new file mode 100644 index 0000000..db17d24 --- /dev/null +++ b/internal/services/tele/chat/helper/wrap.go @@ -0,0 +1,9 @@ +package helper + +func Pre(msg string) string { + return "
" + msg + "" +} + +func Code(msg string) string { + return "
" + msg + ""
+}
diff --git a/internal/services/tele/chat/reply.go b/internal/services/tele/chat/reply.go
new file mode 100644
index 0000000..1ef6960
--- /dev/null
+++ b/internal/services/tele/chat/reply.go
@@ -0,0 +1,39 @@
+package chat
+
+import (
+ "encoding/json"
+ "fmt"
+ "gopkg.in/telebot.v3"
+ "me.thuanle/bbot/internal/services/tele/chat/helper"
+)
+
+func ReplyMessage(context telebot.Context, formattedText string, opts ...interface{}) error {
+ if opts == nil {
+ return context.Reply(formattedText, telebot.ModeHTML, telebot.NoPreview)
+ }
+ return context.Reply(formattedText, append(opts, telebot.ModeHTML, telebot.NoPreview)...)
+}
+
+func ReplyMessagef(context telebot.Context, menu *telebot.ReplyMarkup, format string, v ...interface{}) error {
+ if menu != nil {
+ return ReplyMessage(context, fmt.Sprintf(format, v...), menu)
+ } else {
+ return ReplyMessage(context, fmt.Sprintf(format, v...))
+ }
+}
+
+func ReplyMessageCode(context telebot.Context, message string) error {
+ return ReplyMessage(context, helper.Code(message))
+}
+
+func ReplyMessagePre(context telebot.Context, message string) error {
+ return ReplyMessage(context, helper.Pre(message))
+}
+
+func ReplyMessageJson(context telebot.Context, obj any) error {
+ jsonStr, err := json.MarshalIndent(obj, "", " ")
+ if err != nil {
+ return err
+ }
+ return ReplyMessagePre(context, string(jsonStr))
+}
diff --git a/internal/services/tele/command.go b/internal/services/tele/command.go
new file mode 100644
index 0000000..ff50cf2
--- /dev/null
+++ b/internal/services/tele/command.go
@@ -0,0 +1,45 @@
+package tele
+
+import (
+ "github.com/rs/zerolog/log"
+ "gopkg.in/telebot.v3"
+ "me.thuanle/bbot/internal/services/tele/commands"
+ "me.thuanle/bbot/internal/services/tele/middlewares"
+)
+
+var commandList = []telebot.Command{
+ {
+ Text: "p",
+ Description: "(p) - Get mark price",
+ },
+ {
+ Text: "fee",
+ Description: "(f) - show top funding fee",
+ },
+}
+
+func setupCommands(b *telebot.Bot) error {
+ if err := b.SetCommands(commandList); err != nil {
+ log.Fatal().Err(err).Msg("setup telebot commands")
+ return err
+ }
+
+ b.Use(middlewares.IgnoreBot)
+ b.Use(middlewares.SendErrorMiddleware)
+
+ //welcome
+ b.Handle("/start", commands.OnStart)
+
+ //general
+ b.Handle("/ip", commands.OnGetIp)
+
+ //info
+ b.Handle("/p", commands.OnGetTopPrices)
+ b.Handle("/fee", commands.OnGetTopFundingFee)
+
+ //any text
+ b.Handle(telebot.OnText, commands.OnChatHandler)
+ b.Handle(telebot.OnSticker, commands.OnStickerHandler)
+
+ return nil
+}
diff --git a/internal/services/tele/commands/chat.go b/internal/services/tele/commands/chat.go
new file mode 100644
index 0000000..fa128d8
--- /dev/null
+++ b/internal/services/tele/commands/chat.go
@@ -0,0 +1,43 @@
+package commands
+
+import (
+ "github.com/rs/zerolog/log"
+ "gopkg.in/telebot.v3"
+ "me.thuanle/bbot/internal/configs/tele"
+ "me.thuanle/bbot/internal/helper/binancex"
+ "strings"
+)
+
+func OnChatHandler(context telebot.Context) error {
+ text := strings.ToLower(context.Text())
+
+ switch {
+ case text == "p":
+ return OnGetTopPrices(context)
+ case text == "f" || text == "fee":
+ return OnGetTopFundingFee(context)
+ case binancex.IsToken(text):
+ return OnTokenInfo(context)
+ default:
+ //ignore it
+ }
+ return nil
+}
+
+func OnStickerHandler(context telebot.Context) error {
+ stickerId := context.Message().Sticker.UniqueID
+ switch stickerId {
+ case tele.StickerTop:
+ return OnGetTopPrices(context)
+ default:
+ token, ok := tele.Sticker2TokenMap[stickerId]
+ if !ok {
+ log.Debug().
+ Any("sticker", context.Message().Sticker).
+ Msg("Sticker received")
+ return nil
+ }
+
+ return OnTokenInfoByToken(context, token)
+ }
+}
diff --git a/internal/services/tele/commands/general.go b/internal/services/tele/commands/general.go
new file mode 100644
index 0000000..371c4a4
--- /dev/null
+++ b/internal/services/tele/commands/general.go
@@ -0,0 +1,12 @@
+package commands
+
+import (
+ "gopkg.in/telebot.v3"
+ "me.thuanle/bbot/internal/services/tele/chat"
+ "me.thuanle/bbot/internal/utils/netx"
+)
+
+func OnGetIp(context telebot.Context) error {
+ ip := netx.GetPublicIp()
+ return chat.ReplyMessageCode(context, ip)
+}
diff --git a/internal/services/tele/commands/helper/error.go b/internal/services/tele/commands/helper/error.go
new file mode 100644
index 0000000..cd6d74d
--- /dev/null
+++ b/internal/services/tele/commands/helper/error.go
@@ -0,0 +1,5 @@
+package helper
+
+import "errors"
+
+var ErrorParamCountMismatch = errors.New("param count mismatched")
diff --git a/internal/services/tele/commands/helper/handler.go b/internal/services/tele/commands/helper/handler.go
new file mode 100644
index 0000000..2e46d62
--- /dev/null
+++ b/internal/services/tele/commands/helper/handler.go
@@ -0,0 +1,41 @@
+package helper
+
+import (
+ "gopkg.in/telebot.v3"
+)
+
+func throwErrorParamCountMismatch(_ telebot.Context) error {
+ return ErrorParamCountMismatch
+}
+
+// Base on the number of Args in context, it will execute the (n+1)-th function in the list
+// - If the context have 0 argument, it will call funcs[0]
+// - If the context have 1 argument, it will call funcs[1]
+// Otherwise, it will call `other` function
+func HandleZ0nArgs(other telebot.HandlerFunc, funcs ...telebot.HandlerFunc) telebot.HandlerFunc {
+ return func(context telebot.Context) error {
+ args := context.Args()
+
+ if len(args) > len(funcs)-1 {
+ return other(context)
+ }
+ return funcs[len(args)](context)
+ }
+}
+
+// Base on the number of Args in context, it will execute the (n+1)-th function in the list
+// - If the context have 0 argument, it will call funcs[0]
+// - If the context have 1 argument, it will call funcs[1]
+// Otherwise, it will throw error
+func Handle0nArgs(funcs ...telebot.HandlerFunc) telebot.HandlerFunc {
+ return HandleZ0nArgs(throwErrorParamCountMismatch, funcs...)
+}
+
+func HandleGetSet(getFunc telebot.HandlerFunc, setFunc telebot.HandlerFunc) telebot.HandlerFunc {
+ return func(context telebot.Context) error {
+ if len(context.Args()) == 0 {
+ return getFunc(context)
+ }
+ return setFunc(context)
+ }
+}
diff --git a/internal/services/tele/commands/market.go b/internal/services/tele/commands/market.go
new file mode 100644
index 0000000..49e0001
--- /dev/null
+++ b/internal/services/tele/commands/market.go
@@ -0,0 +1,18 @@
+package commands
+
+import (
+ "gopkg.in/telebot.v3"
+ "me.thuanle/bbot/internal/services/controllers"
+ "me.thuanle/bbot/internal/services/tele/chat"
+ "me.thuanle/bbot/internal/services/tele/view"
+)
+
+func OnGetTopPrices(context telebot.Context) error {
+ sym, price, rate := controllers.GetTopPrices()
+ return chat.ReplyMessagePre(context, view.RenderOnGetTopPricesMessage(sym, price, rate))
+}
+
+func OnGetTopFundingFee(context telebot.Context) error {
+ fee, float64s, cds := controllers.GetTopFundingFee()
+ return chat.ReplyMessagePre(context, view.RenderOnGetTopFundingFeeMessage(fee, float64s, cds))
+}
diff --git a/internal/services/tele/commands/start.go b/internal/services/tele/commands/start.go
new file mode 100644
index 0000000..d4edb56
--- /dev/null
+++ b/internal/services/tele/commands/start.go
@@ -0,0 +1,11 @@
+package commands
+
+import "gopkg.in/telebot.v3"
+
+const what = `Welcome on board! Here is some tips:
+- /p to get pricing
+- /l to list current positions`
+
+func OnStart(context telebot.Context) error {
+ return context.Send(what)
+}
diff --git a/internal/services/tele/commands/token.go b/internal/services/tele/commands/token.go
new file mode 100644
index 0000000..2ac265f
--- /dev/null
+++ b/internal/services/tele/commands/token.go
@@ -0,0 +1,49 @@
+package commands
+
+import (
+ "gopkg.in/telebot.v3"
+ "me.thuanle/bbot/internal/configs/tele"
+ "me.thuanle/bbot/internal/data"
+ "me.thuanle/bbot/internal/helper/binancex"
+ "me.thuanle/bbot/internal/services/tele/chat"
+ "me.thuanle/bbot/internal/services/tele/view"
+ "strings"
+)
+
+func showStickerMode(context telebot.Context, token string) {
+ token = strings.ToUpper(token)
+ stickerIdx, ok := tele.Token2StickerIdxMap[token]
+ if !ok {
+ return
+ }
+ stickers, err := context.Bot().StickerSet(tele.StickerSet)
+ if err == nil && stickers != nil {
+ _ = context.Reply(&stickers.Stickers[stickerIdx])
+ }
+}
+
+func OnTokenInfoByToken(context telebot.Context, token string) error {
+ symbols := binancex.Token2Symbols(token)
+ if len(symbols) == 0 {
+ return nil
+ }
+
+ showStickerMode(context, token)
+
+ fp, fundRate, fundTime, ok := data.Market.GetFuturePrice(symbols[0])
+ if !ok {
+ return nil
+ }
+ sSymbol := binancex.Future2SpotSymbol(symbols[0])
+ sp, _ := data.Market.GetSpotPrice(sSymbol)
+
+ _ = chat.ReplyMessage(context, view.RenderOnPriceMessage(symbols[0], sp, fp, fundRate, fundTime))
+
+ return nil
+}
+
+func OnTokenInfo(context telebot.Context) error {
+ text := strings.ToLower(context.Text())
+
+ return OnTokenInfoByToken(context, text)
+}
diff --git a/internal/services/tele/middlewares/error.go b/internal/services/tele/middlewares/error.go
new file mode 100644
index 0000000..cc2a435
--- /dev/null
+++ b/internal/services/tele/middlewares/error.go
@@ -0,0 +1,19 @@
+package middlewares
+
+import (
+ "fmt"
+ "github.com/rs/zerolog/log"
+ "gopkg.in/telebot.v3"
+)
+
+func SendErrorMiddleware(next telebot.HandlerFunc) telebot.HandlerFunc {
+ return func(c telebot.Context) error {
+ err := next(c) // continue execution chain
+ if err != nil {
+ log.Debug().Err(err).Msg("Chat Middleware error reply")
+ return c.Reply(fmt.Sprintf("Error: %v", err), telebot.RemoveKeyboard)
+ }
+ return nil
+ }
+
+}
diff --git a/internal/services/tele/middlewares/ignore_bot.go b/internal/services/tele/middlewares/ignore_bot.go
new file mode 100644
index 0000000..da5a0df
--- /dev/null
+++ b/internal/services/tele/middlewares/ignore_bot.go
@@ -0,0 +1,14 @@
+package middlewares
+
+import (
+ "gopkg.in/telebot.v3"
+)
+
+func IgnoreBot(next telebot.HandlerFunc) telebot.HandlerFunc {
+ return func(c telebot.Context) error {
+ if c.Sender().IsBot {
+ return nil
+ }
+ return next(c)
+ }
+}
diff --git a/internal/services/tele/tele_service.go b/internal/services/tele/tele_service.go
new file mode 100644
index 0000000..061e7d2
--- /dev/null
+++ b/internal/services/tele/tele_service.go
@@ -0,0 +1,51 @@
+package tele
+
+import (
+ "github.com/rs/zerolog/log"
+ "gopkg.in/telebot.v3"
+ "me.thuanle/bbot/internal/configs/key"
+ "os"
+ "sync"
+ "time"
+)
+
+type Service struct {
+ b *telebot.Bot
+}
+
+var (
+ ts *Service
+ once sync.Once
+)
+
+func Ins() *Service {
+ if ts != nil {
+ return ts
+ }
+ once.Do(func() {
+ pref := telebot.Settings{
+ Token: os.Getenv(key.TelegramToken),
+ Poller: &telebot.LongPoller{Timeout: 10 * time.Second},
+ }
+
+ b, err := telebot.NewBot(pref)
+ if err != nil {
+ log.Fatal().Err(err).Msg("Failed to create bot")
+ }
+
+ err = setupCommands(b)
+ if err != nil {
+ log.Fatal().Err(err).Msg("Failed to setup commands")
+ }
+
+ ts = &Service{
+ b: b,
+ }
+ })
+ return ts
+}
+
+func (t *Service) Start() {
+ log.Info().Msg("Starting telegram service")
+ t.b.Start()
+}
diff --git a/internal/services/tele/view/fee.go b/internal/services/tele/view/fee.go
new file mode 100644
index 0000000..9be3ed5
--- /dev/null
+++ b/internal/services/tele/view/fee.go
@@ -0,0 +1,34 @@
+package view
+
+import (
+ "fmt"
+ "github.com/jedib0t/go-pretty/v6/table"
+ "me.thuanle/bbot/internal/helper/binancex"
+ "me.thuanle/bbot/internal/services/tele/view/helper"
+ "me.thuanle/bbot/internal/utils/opx"
+ "me.thuanle/bbot/internal/utils/timex"
+ "time"
+)
+
+func IconOfFundingFeeDirection(fund float64) string {
+ return opx.Ite(fund > 0, "📉", "📈")
+}
+
+func RenderOnGetTopFundingFeeMessage(sym []string, rate []float64, cd []int64) string {
+ t := helper.NewNoBorderTableWriter("Symbol", "Rate", "Cd")
+
+ flag := true
+ for i, s := range sym {
+ if rate[i] > 0 && flag {
+ t.AppendRow(table.Row{"...", "...", "..."})
+ flag = false
+ }
+ t.AppendRow(table.Row{
+ binancex.Symbol2Token(s),
+ fmt.Sprintf("%.3f%%", rate[i]*100),
+ fmt.Sprintf("%s", timex.CdMinuteStringTime(time.UnixMilli(cd[i]))),
+ })
+ }
+
+ return t.Render()
+}
diff --git a/internal/services/tele/view/helper/table.go b/internal/services/tele/view/helper/table.go
new file mode 100644
index 0000000..114b89c
--- /dev/null
+++ b/internal/services/tele/view/helper/table.go
@@ -0,0 +1,27 @@
+package helper
+
+import (
+ "github.com/jedib0t/go-pretty/v6/table"
+ "github.com/jedib0t/go-pretty/v6/text"
+)
+
+func NewNoBorderTableWriter(headers ...string) table.Writer {
+ t := table.NewWriter()
+ t.Style().Options.DrawBorder = false
+ t.Style().Options.SeparateColumns = false
+
+ var configs []table.ColumnConfig
+ configs = append(configs, table.ColumnConfig{Number: 1, AlignHeader: text.AlignCenter, Align: text.AlignLeft, AlignFooter: text.AlignLeft})
+ for i := 2; i <= len(headers); i++ {
+ configs = append(configs, table.ColumnConfig{Number: i, AlignHeader: text.AlignCenter, Align: text.AlignRight, AlignFooter: text.AlignRight})
+ }
+ t.SetColumnConfigs(configs)
+
+ var h table.Row
+ for _, header := range headers {
+ h = append(h, header)
+ }
+ t.AppendHeader(h)
+
+ return t
+}
diff --git a/internal/services/tele/view/price.go b/internal/services/tele/view/price.go
new file mode 100644
index 0000000..279b3b6
--- /dev/null
+++ b/internal/services/tele/view/price.go
@@ -0,0 +1,60 @@
+package view
+
+import (
+ "fmt"
+ "github.com/jedib0t/go-pretty/v6/table"
+ "golang.org/x/text/language"
+ "golang.org/x/text/message"
+ "me.thuanle/bbot/internal/helper/binancex"
+ "me.thuanle/bbot/internal/services/tele/view/helper"
+ "me.thuanle/bbot/internal/utils/timex"
+ "time"
+)
+
+func RenderPrice(price float64) string {
+ mFmt := message.NewPrinter(language.AmericanEnglish)
+ switch {
+ case price > 1000:
+ return mFmt.Sprintf("%0.0f", price)
+ case price > 100:
+ return mFmt.Sprintf("%0.1f", price)
+ case price > 10:
+ return mFmt.Sprintf("%0.2f", price)
+ case price > 1:
+ return mFmt.Sprintf("%0.3f", price)
+ case price > 0.1:
+ return mFmt.Sprintf("%0.4f", price)
+ case price > 0.01:
+ return mFmt.Sprintf("%0.5f", price)
+ case price > 0.001:
+ return mFmt.Sprintf("%0.6f", price)
+ case price > 0.0001:
+ return mFmt.Sprintf("%0.7f", price)
+ default:
+ return mFmt.Sprintf("%0.8f", price)
+ }
+}
+
+func RenderOnPriceMessage(symbol string, spotPrice float64, futurePrice float64, fundrate float64, fundtime int64) string {
+ return fmt.Sprintf(
+ "%s: %s\n"+
+ "Fund rate: %.4f%% %s in %s\n"+
+ "Spot: %s",
+ RenderSymbolInfo(symbol), RenderPrice(futurePrice),
+ fundrate*100, IconOfFundingFeeDirection(fundrate), timex.CdMinuteStringTime(time.UnixMilli(fundtime)),
+ RenderPrice(spotPrice),
+ )
+}
+
+func RenderOnGetTopPricesMessage(symbols []string, price []float64, fundRate []float64) string {
+ t := helper.NewNoBorderTableWriter("", "Price", "Rate")
+ mFmt := message.NewPrinter(language.AmericanEnglish)
+ for i, symbol := range symbols {
+ t.AppendRow(table.Row{
+ binancex.Symbol2Token(symbol),
+ mFmt.Sprintf("%.2f", price[i]),
+ mFmt.Sprintf("%.3f%%", fundRate[i]*100),
+ })
+ }
+ return t.Render()
+}
diff --git a/internal/services/tele/view/symbol.go b/internal/services/tele/view/symbol.go
new file mode 100644
index 0000000..8aa3753
--- /dev/null
+++ b/internal/services/tele/view/symbol.go
@@ -0,0 +1,11 @@
+package view
+
+import (
+ "fmt"
+ "me.thuanle/bbot/internal/helper/binancex"
+)
+
+func RenderSymbolInfo(sym string) string {
+ token := binancex.Symbol2Token(sym)
+ return fmt.Sprintf("%s", sym, token)
+}
diff --git a/internal/utils/collectionx/count_map.go b/internal/utils/collectionx/count_map.go
new file mode 100644
index 0000000..237b165
--- /dev/null
+++ b/internal/utils/collectionx/count_map.go
@@ -0,0 +1,42 @@
+package collectionx
+
+import "github.com/samber/lo"
+
+type CountMapN[T comparable] struct {
+ N int
+ Map map[T][]int
+}
+
+func NewCountMapN[T comparable](n int) *CountMapN[T] {
+ return &CountMapN[T]{
+ N: n,
+ Map: make(map[T][]int),
+ }
+}
+
+func (cm *CountMapN[T]) Inc(key T, ith int, value int) {
+ m, ok := cm.Map[key]
+ if !ok {
+ m = make([]int, cm.N)
+ cm.Map[key] = m
+ }
+
+ m[ith] += value
+}
+
+func (cm *CountMapN[T]) Get(key T, ith int) int {
+ m, ok := cm.Map[key]
+ if !ok {
+ return 0
+ }
+
+ return m[ith]
+}
+
+func (cm *CountMapN[T]) Sum(key T) int {
+ m, ok := cm.Map[key]
+ if !ok {
+ return 0
+ }
+ return lo.Sum(m)
+}
diff --git a/internal/utils/convertx/number.go b/internal/utils/convertx/number.go
new file mode 100644
index 0000000..9f96c84
--- /dev/null
+++ b/internal/utils/convertx/number.go
@@ -0,0 +1,31 @@
+package convertx
+
+import "strconv"
+
+func Int642String(i int64) string {
+ return strconv.FormatInt(i, 10)
+}
+
+func String2Float64(s string, defValue ...float64) float64 {
+ f64, err := strconv.ParseFloat(s, 64)
+ if err != nil {
+ return defValue[0]
+ }
+ return f64
+}
+
+func String2Int(s string, defValue ...int) int {
+ i64, err := strconv.Atoi(s)
+ if err != nil {
+ return defValue[0]
+ }
+ return i64
+}
+
+func String2Int64(s string, defValue ...int64) int64 {
+ i64, err := strconv.ParseInt(s, 10, 64)
+ if err != nil {
+ return defValue[0]
+ }
+ return i64
+}
diff --git a/internal/utils/envx/envx.go b/internal/utils/envx/envx.go
new file mode 100644
index 0000000..47a12c5
--- /dev/null
+++ b/internal/utils/envx/envx.go
@@ -0,0 +1,47 @@
+package envx
+
+import (
+ "encoding/json"
+ "errors"
+ "github.com/rs/zerolog/log"
+ "me.thuanle/bbot/internal/configs"
+ "os"
+)
+
+func GetEnvJsonArray[T any](key string) ([]T, error) {
+ values, found := os.LookupEnv(key)
+ if !found {
+ return nil, errors.New("env not found. Key: " + key)
+ }
+ var des []T
+ err := json.Unmarshal([]byte(values), &des)
+ if configs.LogEnv {
+ if err != nil {
+ log.Error().Err(err).
+ Str("key", key).
+ Str("value", values).
+ Msg("Failed to load env")
+ } else {
+ log.Info().
+ Str("key", key).
+ Interface("value", des).
+ Msg("Loaded env")
+ }
+ }
+ return des, err
+}
+
+func GetEnvBool(key string) (bool, error) {
+ value, found := os.LookupEnv(key)
+ if !found {
+ return false, errors.New("env not found. Key: " + key)
+ }
+ val := value == "true" || value == "1"
+ if configs.LogEnv {
+ log.Info().
+ Str("key", key).
+ Bool("value", val).
+ Msg("Loaded env")
+ }
+ return val, nil
+}
diff --git a/internal/utils/errorx/error.go b/internal/utils/errorx/error.go
new file mode 100644
index 0000000..a434fb1
--- /dev/null
+++ b/internal/utils/errorx/error.go
@@ -0,0 +1,9 @@
+package errorx
+
+import "github.com/rs/zerolog/log"
+
+func PanicOnError(err error, msg string) {
+ if err != nil {
+ log.Panic().Err(err).Msg(msg)
+ }
+}
diff --git a/internal/utils/mathx/maxmin.go b/internal/utils/mathx/maxmin.go
new file mode 100644
index 0000000..3ef4337
--- /dev/null
+++ b/internal/utils/mathx/maxmin.go
@@ -0,0 +1,23 @@
+package mathx
+
+import "cmp"
+
+func MaxN[T cmp.Ordered](a T, b ...T) T {
+ m := a
+ for _, v := range b {
+ if v > m {
+ m = v
+ }
+ }
+ return m
+}
+
+func MinN[T cmp.Ordered](a T, b ...T) T {
+ m := a
+ for _, v := range b {
+ if v < m {
+ m = v
+ }
+ }
+ return m
+}
diff --git a/internal/utils/netx/ip.go b/internal/utils/netx/ip.go
new file mode 100644
index 0000000..7db66b1
--- /dev/null
+++ b/internal/utils/netx/ip.go
@@ -0,0 +1,21 @@
+package netx
+
+import (
+ "io"
+ "net/http"
+)
+
+func GetPublicIp() string {
+ req, err := http.Get("http://ip.thuanle.me")
+ if err != nil {
+ return err.Error()
+ }
+ defer req.Body.Close()
+
+ body, err := io.ReadAll(req.Body)
+ if err != nil {
+ return err.Error()
+ }
+
+ return string(body)
+}
diff --git a/internal/utils/opx/ternary.go b/internal/utils/opx/ternary.go
new file mode 100644
index 0000000..8ca5c0c
--- /dev/null
+++ b/internal/utils/opx/ternary.go
@@ -0,0 +1,10 @@
+package opx
+
+// Ite If-then-else
+func Ite[T any](condition bool, trueValue T, falseValue T) T {
+ if condition {
+ return trueValue
+ } else {
+ return falseValue
+ }
+}
diff --git a/internal/utils/slicex/slice.go b/internal/utils/slicex/slice.go
new file mode 100644
index 0000000..598a7fe
--- /dev/null
+++ b/internal/utils/slicex/slice.go
@@ -0,0 +1,9 @@
+package slicex
+
+import "sort"
+
+func Sort[T any](slice []T, compareTo func(o1, o2 T) bool) {
+ sort.Slice(slice, func(i, j int) bool {
+ return compareTo(slice[i], slice[j])
+ })
+}
diff --git a/internal/utils/stringx/replace.go b/internal/utils/stringx/replace.go
new file mode 100644
index 0000000..fe37f15
--- /dev/null
+++ b/internal/utils/stringx/replace.go
@@ -0,0 +1,11 @@
+package stringx
+
+import "strings"
+
+func ReplaceSuffix(s string, suffix string, newSuffix string) (string, bool) {
+ if strings.HasSuffix(s, suffix) {
+ replaced := s[:len(s)-len(suffix)] + newSuffix
+ return replaced, true
+ }
+ return s, false
+}
diff --git a/internal/utils/stringx/validation.go b/internal/utils/stringx/validation.go
new file mode 100644
index 0000000..9a65046
--- /dev/null
+++ b/internal/utils/stringx/validation.go
@@ -0,0 +1,9 @@
+package stringx
+
+import "regexp"
+
+func IsAlphaNumeric(s string) bool {
+ match, _ := regexp.MatchString("^[a-zA-Z0-9]*$", s)
+ return match
+
+}
diff --git a/internal/utils/timex/cd.go b/internal/utils/timex/cd.go
new file mode 100644
index 0000000..d686d9f
--- /dev/null
+++ b/internal/utils/timex/cd.go
@@ -0,0 +1,19 @@
+package timex
+
+import (
+ "strings"
+ "time"
+)
+
+func CdMinuteStringDuration(dur time.Duration) string {
+ dur -= dur % time.Minute
+ rString := dur.String()
+ if strings.HasSuffix(rString, "0s") {
+ rString = rString[:len(rString)-2]
+ }
+ return rString
+}
+
+func CdMinuteStringTime(t time.Time) string {
+ return CdMinuteStringDuration(-time.Since(t))
+}
diff --git a/internal/utils/typex/pair.go b/internal/utils/typex/pair.go
new file mode 100644
index 0000000..d0f43e8
--- /dev/null
+++ b/internal/utils/typex/pair.go
@@ -0,0 +1,6 @@
+package typex
+
+type Pair[T any, V any] struct {
+ First T
+ Second V
+}