diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4d72fe9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea/ +test/ \ No newline at end of file diff --git a/api.go b/api.go new file mode 100644 index 0000000..9a2babc --- /dev/null +++ b/api.go @@ -0,0 +1,68 @@ +package laniakea + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" +) + +type ApiResponse struct { + Ok bool `json:"ok"` + Description string `json:"description,omitempty"` + Result any `json:"result,omitempty"` + ErrorCode int `json:"error_code,omitempty"` +} + +// request is a low-level call to api. +func (b *Bot) request(methodName string, params any) (map[string]any, error) { + var buf bytes.Buffer + err := json.NewEncoder(&buf).Encode(params) + if err != nil { + return nil, err + } + + if b.debug && b.requestLogger != nil { + b.requestLogger.Debugln(strings.ReplaceAll(fmt.Sprintf( + "POST https://api.telegram.org/bot%s/%s %s", + "", + methodName, + buf.String(), + ), "\n", "")) + } + r, err := http.Post(fmt.Sprintf("https://api.telegram.org/bot%s/%s", b.token, methodName), "application/json", &buf) + if err != nil { + return nil, err + } + defer r.Body.Close() + data, err := io.ReadAll(r.Body) + if err != nil { + return nil, err + } + b.requestLogger.Debugln(fmt.Sprintf("RES %s %s", methodName, string(data))) + + response := new(ApiResponse) + err = json.Unmarshal(data, &response) + if err != nil { + return nil, err + } + + if !response.Ok { + return nil, fmt.Errorf("[%d] %s", response.ErrorCode, response.Description) + } + if res, ok := response.Result.(bool); ok { + return map[string]any{ + "data": res, + }, nil + } else if res, ok := response.Result.([]any); ok { + return map[string]any{ + "data": res, + }, nil + } else if res, ok := response.Result.(map[string]any); ok { + return res, nil + } + return map[string]any{}, errors.New("can't parse response") +} diff --git a/bot.go b/bot.go index 57d96e2..91b92b9 100644 --- a/bot.go +++ b/bot.go @@ -1,18 +1,18 @@ package laniakea import ( - "bytes" "encoding/json" "fmt" - "io" - "net/http" + "log" "os" "sort" "strings" + "time" + "git.nix13.pw/scuroneko/slog" "github.com/redis/go-redis/v9" + "github.com/vinovest/sqlx" "go.mongodb.org/mongo-driver/v2/mongo" - "gorm.io/gorm" ) type ParseMode string @@ -28,12 +28,13 @@ type Bot struct { debug bool errorTemplate string - logger *Logger - requestLogger *Logger + logger *slog.Logger + requestLogger *slog.Logger plugins []*Plugin middlewares []*Middleware prefixes []string + runners []Runner dbContext *DatabaseContext @@ -50,6 +51,7 @@ type BotSettings struct { UpdateTypes []string LoggerBasePath string UseRequestLogger bool + WriteToFile bool } func LoadSettingsFromEnv() *BotSettings { @@ -60,30 +62,22 @@ func LoadSettingsFromEnv() *BotSettings { Prefixes: LoadPrefixesFromEnv(), UpdateTypes: strings.Split(os.Getenv("UPDATE_TYPES"), ";"), UseRequestLogger: os.Getenv("USE_REQ_LOG") == "true", + WriteToFile: os.Getenv("WRITE_TO_FILE") == "true", } } -type MsgContext struct { - Bot *Bot - Msg *Message - Update *Update - FromID int - Prefix string - Text string - Args []string +func LoadPrefixesFromEnv() []string { + prefixesS, exists := os.LookupEnv("PREFIXES") + if !exists { + return []string{"!"} + } + return strings.Split(prefixesS, ";") } - -type DatabaseContext struct { - PostgresSQL *gorm.DB - MongoDB *mongo.Client - Redis *redis.Client -} - func NewBot(settings *BotSettings) *Bot { updateQueue := CreateQueue[*Update](256) bot := &Bot{ updateOffset: 0, plugins: make([]*Plugin, 0), debug: settings.Debug, errorTemplate: "%s", - prefixes: settings.Prefixes, updateTypes: make([]string, 0), + prefixes: settings.Prefixes, updateTypes: make([]string, 0), runners: make([]Runner, 0), updateQueue: updateQueue, token: settings.Token, } @@ -91,45 +85,72 @@ func NewBot(settings *BotSettings) *Bot { if len(settings.ErrorTemplate) > 0 { bot.errorTemplate = settings.ErrorTemplate } - if len(settings.LoggerBasePath) == 0 { settings.LoggerBasePath = "./" } - level := FATAL + + level := slog.FATAL if settings.Debug { - level = DEBUG + level = slog.DEBUG } - bot.logger = CreateLogger().Level(level).OpenFile(fmt.Sprintf("%s/main.log", strings.TrimRight(settings.LoggerBasePath, "/"))) - bot.logger = bot.logger.PrintTraceback(true) + + bot.logger = slog.CreateLogger().Level(level).Prefix("BOT") + bot.logger.AddWriter(bot.logger.CreateJsonStdoutWriter()) + if settings.WriteToFile { + path := fmt.Sprintf("%s/main.log", strings.TrimRight(settings.LoggerBasePath, "/")) + fileWriter, err := bot.logger.CreateTextFileWriter(path) + if err != nil { + bot.logger.Fatal(err) + } + bot.logger.AddWriter(fileWriter) + } + if settings.UseRequestLogger { - bot.requestLogger = CreateLogger().Level(level).Prefix("REQUESTS").OpenFile(fmt.Sprintf("%s/requests.log", strings.TrimRight(settings.LoggerBasePath, "/"))) + bot.requestLogger = slog.CreateLogger().Level(level).Prefix("REQUESTS") + bot.requestLogger.AddWriter(bot.requestLogger.CreateJsonStdoutWriter()) + if settings.WriteToFile { + path := fmt.Sprintf("%s/requests.log", strings.TrimRight(settings.LoggerBasePath, "/")) + fileWriter, err := bot.requestLogger.CreateTextFileWriter(path) + if err != nil { + bot.logger.Fatal(err) + } + bot.requestLogger.AddWriter(fileWriter) + } } return bot } func (b *Bot) Close() { - err := b.logger.f.Close() + err := b.logger.Close() if err != nil { - fmt.Println(err) - } else { - fmt.Println("log closed") + log.Println(err) + } + err = b.requestLogger.Close() + if err != nil { + log.Println(err) } } -func (b *Bot) InitDatabaseContext(ctx *DatabaseContext) *Bot { +type DatabaseContext struct { + PostgresSQL *sqlx.DB + MongoDB *mongo.Client + Redis *redis.Client +} + +func (b *Bot) AddDatabaseLogger(writer func(db *DatabaseContext) slog.LoggerWriter) *Bot { + w := writer(b.dbContext) + b.logger.AddWriter(w) + if b.requestLogger != nil { + b.requestLogger.AddWriter(w) + } + return b +} + +func (b *Bot) DatabaseContext(ctx *DatabaseContext) *Bot { b.dbContext = ctx return b } -func (b *Bot) AddDatabaseLogger(writer func(db *DatabaseContext) LoggerWriter) *Bot { - w := []LoggerWriter{writer(b.dbContext)} - b.logger.AddWriters(w) - if b.requestLogger != nil { - b.requestLogger.AddWriters(w) - } - return b -} - func (b *Bot) UpdateTypes(t ...string) *Bot { b.updateTypes = make([]string, 0) b.updateTypes = append(b.updateTypes, t...) @@ -139,38 +160,25 @@ func (b *Bot) AddUpdateType(t ...string) *Bot { b.updateTypes = append(b.updateTypes, t...) return b } - func (b *Bot) AddPrefixes(prefixes ...string) *Bot { b.prefixes = append(b.prefixes, prefixes...) return b } - -func LoadPrefixesFromEnv() []string { - prefixes, exists := os.LookupEnv("PREFIXES") - if !exists { - return []string{"!"} - } - return strings.Split(prefixes, ";") -} - func (b *Bot) ErrorTemplate(s string) *Bot { b.errorTemplate = s return b } - func (b *Bot) Debug(debug bool) *Bot { b.debug = debug return b } - func (b *Bot) AddPlugins(plugin ...*Plugin) *Bot { b.plugins = append(b.plugins, plugin...) for _, p := range plugin { - b.logger.Debug(fmt.Sprintf("plugins with name \"%s\" was registered", p.Name)) + b.logger.Debugln(fmt.Sprintf("plugins with name \"%s\" registered", p.Name)) } return b } - func (b *Bot) AddMiddleware(middleware ...*Middleware) *Bot { sort.Slice(middleware, func(a, b int) bool { first := middleware[a] @@ -183,29 +191,42 @@ func (b *Bot) AddMiddleware(middleware ...*Middleware) *Bot { b.middlewares = append(b.middlewares, middleware...) for _, m := range middleware { - b.logger.Debug(fmt.Sprintf("middleware with name \"%s\" was registered", m.Name)) + b.logger.Debugln(fmt.Sprintf("middleware with name \"%s\" registered", m.Name)) } return b } +func (b *Bot) AddRunner(runner Runner) *Bot { + b.runners = append(b.runners, runner) + b.logger.Debugln(fmt.Sprintf("runner with name \"%s\" registered", runner.Name)) + return b +} +func (b *Bot) Logger() *slog.Logger { + return b.logger +} +func (b *Bot) GetDBContext() *DatabaseContext { + return b.dbContext +} func (b *Bot) Run() { if len(b.prefixes) == 0 { - b.logger.Fatal("no prefixes defined") + b.logger.Fatalln("no prefixes defined") return } if len(b.plugins) == 0 { - b.logger.Fatal("no plugins defined") + b.logger.Fatalln("no plugins defined") return } - b.logger.Info("Bot running. Press CTRL+C to exit.") + b.logger.Infoln("Executing runners...") + b.ExecRunners() + b.logger.Infoln("Bot running. Press CTRL+C to exit.") go func() { for { _, err := b.Updates() if err != nil { - b.logger.Error(err) + b.logger.Errorln(err) } } }() @@ -213,13 +234,17 @@ func (b *Bot) Run() { for { queue := b.updateQueue if queue.IsEmpty() { + time.Sleep(time.Millisecond * 25) continue } u := queue.Dequeue() + if u == nil { + b.logger.Errorln("update is nil") + continue + } ctx := &MsgContext{ - Bot: b, - Update: u, + Bot: b, Update: u, } for _, middleware := range b.middlewares { middleware.Execute(ctx, b.dbContext) @@ -242,24 +267,26 @@ func (b *Bot) Run() { // {"callback_query":{"chat_instance":"6202057960757700762","data":"aboba","from":{"first_name":"scuroneko","id":314834933,"is_bot":false,"language_code":"ru","username":"scuroneko"},"id":"1352205741990111553","message":{"chat":{"first_name":"scuroneko","id":314834933,"type":"private","username":"scuroneko"},"date":1734338107,"from":{"first_name":"Kurumi","id":7718900880,"is_bot":true,"username":"kurumi_game_bot"},"message_id":19,"reply_markup":{"inline_keyboard":[[{"callback_data":"aboba","text":"Test"},{"callback_data":"another","text":"Another"}]]},"text":"Aboba"}},"update_id":350979488} func (b *Bot) handleMessage(update *Update, ctx *MsgContext) { - var text string if update.Message == nil { return } + + var text string if len(update.Message.Text) > 0 { text = update.Message.Text } else { text = update.Message.Caption } - ctx.FromID = update.Message.From.ID - ctx.Msg = update.Message text = strings.TrimSpace(text) prefix, hasPrefix := b.checkPrefixes(text) if !hasPrefix { return } ctx.Prefix = prefix + ctx.FromID = update.Message.From.ID + ctx.From = update.Message.From + ctx.Msg = update.Message text = strings.TrimSpace(text[len(prefix):]) @@ -274,18 +301,32 @@ func (b *Bot) handleMessage(update *Update, ctx *MsgContext) { ctx.Args = strings.Split(ctx.Text, " ") go plugin.Execute(cmd, ctx, b.dbContext) + return } } } func (b *Bot) handleCallback(update *Update, ctx *MsgContext) { + data := new(CallbackData) + err := json.Unmarshal([]byte(update.CallbackQuery.Data), data) + if err != nil { + b.logger.Errorln(err) + return + } + + ctx.FromID = update.CallbackQuery.From.ID + ctx.From = update.CallbackQuery.From + ctx.Msg = update.CallbackQuery.Message + ctx.CallbackMsgId = update.CallbackQuery.Message.MessageID + ctx.Args = data.Args + for _, plugin := range b.plugins { - for payload := range plugin.Payloads { - if !strings.HasPrefix(update.CallbackQuery.Data, payload) { - continue - } - go plugin.ExecutePayload(payload, ctx, b.dbContext) + _, ok := plugin.Payloads[data.Command] + if !ok { + continue } + go plugin.ExecutePayload(data.Command, ctx, b.dbContext) + return } } @@ -297,102 +338,3 @@ func (b *Bot) checkPrefixes(text string) (string, bool) { } return "", false } - -func (ctx *MsgContext) Answer(text string) { - _, err := ctx.Bot.SendMessage(&SendMessageP{ - ChatID: ctx.Msg.Chat.ID, - Text: text, - ParseMode: ParseMD, - }) - if err != nil { - ctx.Bot.logger.Error(err) - } -} - -func (ctx *MsgContext) AnswerPhoto(photoId string, text string) { - _, err := ctx.Bot.SendPhoto(&SendPhotoP{ - ChatID: ctx.Msg.Chat.ID, - Caption: text, - Photo: photoId, - ParseMode: ParseMD, - }) - if err != nil { - ctx.Bot.logger.Error(err) - } -} - -func (ctx *MsgContext) Error(err error) { - _, sendErr := ctx.Bot.SendMessage(&SendMessageP{ - ChatID: ctx.Msg.Chat.ID, - Text: fmt.Sprintf(ctx.Bot.errorTemplate, err.Error()), - }) - - if sendErr != nil { - ctx.Bot.logger.Error(sendErr) - } -} - -func (b *Bot) Logger() *Logger { - return b.logger -} - -type ApiResponse struct { - Ok bool `json:"ok"` - Result map[string]any `json:"result,omitempty"` - Description string `json:"description,omitempty"` - ErrorCode int `json:"error_code,omitempty"` -} - -type ApiResponseA struct { - Ok bool `json:"ok"` - Result []any `json:"result,omitempty"` - Description string `json:"description,omitempty"` - ErrorCode int `json:"error_code,omitempty"` -} - -// request is a low-level call to api. -func (b *Bot) request(methodName string, params map[string]any) (map[string]any, error) { - var buf bytes.Buffer - err := json.NewEncoder(&buf).Encode(params) - if err != nil { - return nil, err - } - - if b.debug && b.requestLogger != nil { - b.requestLogger.Debug(strings.ReplaceAll(fmt.Sprintf( - "POST https://api.telegram.org/bot%s/%s %s", - "", - methodName, - buf.String(), - ), "\n", "")) - } - r, err := http.Post(fmt.Sprintf("https://api.telegram.org/bot%s/%s", b.token, methodName), "application/json", &buf) - if err != nil { - return nil, err - } - data, err := io.ReadAll(r.Body) - if err != nil { - return nil, err - } - response := new(ApiResponse) - - var result map[string]any - - err = json.Unmarshal(data, &response) - if err != nil { - responseArray := new(ApiResponseA) - err = json.Unmarshal(data, responseArray) - if err != nil { - return nil, err - } - result = map[string]interface{}{ - "data": responseArray.Result, - } - } else { - result = response.Result - } - if !response.Ok { - return nil, fmt.Errorf("[%d] %s", response.ErrorCode, response.Description) - } - return result, err -} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..79c4658 --- /dev/null +++ b/go.mod @@ -0,0 +1,28 @@ +module git.nix13.pw/scuroneko/laniakea + +go 1.25 + +require ( + git.nix13.pw/scuroneko/slog v1.0.0 + github.com/redis/go-redis/v9 v9.17.3 + github.com/vinovest/sqlx v1.7.1 + go.mongodb.org/mongo-driver/v2 v2.5.0 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/klauspost/compress v1.18.3 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/muir/sqltoken v0.2.1 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.2.0 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..21af68d --- /dev/null +++ b/go.sum @@ -0,0 +1,85 @@ +filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= +git.nix13.pw/scuroneko/slog v1.0.0 h1:PI0YePrmCopjrljUfwCtBIEwNYB+PBgDzPcCXbetpcE= +git.nix13.pw/scuroneko/slog v1.0.0/go.mod h1:3Qm2wzkR5KjwOponMfG7TcGSDjmYaFqRAmLvSPTuWJI= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/go-sql-driver/mysql v1.9.0 h1:Y0zIbQXhQKmQgTp44Y1dp3wTXcn804QoTptLZT1vtvo= +github.com/go-sql-driver/mysql v1.9.0/go.mod h1:pDetrLJeA3oMujJuvXc8RJoasr589B6A9fwzD3QMrqw= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/klauspost/compress v1.18.3 h1:9PJRvfbmTabkOX8moIpXPbMMbYN60bWImDDU7L+/6zw= +github.com/klauspost/compress v1.18.3/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y= +github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= +github.com/muir/sqltoken v0.2.1 h1:19KvJrCj9aOMfU921hjnizWPlQmPTe+tb36zupOY2FA= +github.com/muir/sqltoken v0.2.1/go.mod h1:sSlj5M0VqQ4OuedmxwWs1TmzzRXaH3DLf5ukzg6meIo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.17.3 h1:fN29NdNrE17KttK5Ndf20buqfDZwGNgoUr9qjl1DQx4= +github.com/redis/go-redis/v9 v9.17.3/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/vinovest/sqlx v1.7.1 h1:kdq4v0N9kRLpytWGSWOw4aulOGdQPmIoMR6Y+cTBxow= +github.com/vinovest/sqlx v1.7.1/go.mod h1:3fAv74r4iDMv2PpFomADb+vex5ukzfYn4GseC9KngD8= +github.com/xdg-go/pbkdf2 v1.0.0 h1:Su7DPu48wXMwC3bs7MCNG+z4FhcyEuz5dlvchbq0B0c= +github.com/xdg-go/pbkdf2 v1.0.0/go.mod h1:jrpuAogTd400dnrH08LKmI/xc1MbPOebTwRqcT5RDeI= +github.com/xdg-go/scram v1.2.0 h1:bYKF2AEwG5rqd1BumT4gAnvwU/M9nBp2pTSxeZw7Wvs= +github.com/xdg-go/scram v1.2.0/go.mod h1:3dlrS0iBaWKYVt2ZfA4cj48umJZ+cAEbR6/SjLA88I8= +github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6c8= +github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM= +github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= +go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/keyboard.go b/keyboard.go index edc15f0..bdb9e35 100644 --- a/keyboard.go +++ b/keyboard.go @@ -1,4 +1,74 @@ package laniakea +import ( + "encoding/json" + "fmt" +) + type InlineKeyboard struct { + CurrentLine []InlineKeyboardButton + Lines [][]InlineKeyboardButton + maxRow int +} + +func NewInlineKeyboard(maxRow int) *InlineKeyboard { + return &InlineKeyboard{ + CurrentLine: make([]InlineKeyboardButton, 0), + Lines: make([][]InlineKeyboardButton, 0), + maxRow: maxRow, + } +} + +func (in *InlineKeyboard) append(button InlineKeyboardButton) *InlineKeyboard { + if len(in.CurrentLine) == in.maxRow { + in.AddLine() + } + in.CurrentLine = append(in.CurrentLine, button) + return in +} +func (in *InlineKeyboard) AddUrlButton(text, url string) *InlineKeyboard { + return in.append(InlineKeyboardButton{Text: text, URL: url}) +} +func (in *InlineKeyboard) AddCallbackButton(text string, cmd string, args ...any) *InlineKeyboard { + return in.append(InlineKeyboardButton{Text: text, CallbackData: NewCallbackData(cmd, args...).ToJson()}) +} + +func (in *InlineKeyboard) AddLine() *InlineKeyboard { + if len(in.CurrentLine) == 0 { + return in + } + in.Lines = append(in.Lines, in.CurrentLine) + in.CurrentLine = make([]InlineKeyboardButton, 0) + return in +} +func (in *InlineKeyboard) Get() InlineKeyboardMarkup { + if len(in.CurrentLine) > 0 { + in.Lines = append(in.Lines, in.CurrentLine) + } + return InlineKeyboardMarkup{ + InlineKeyboard: in.Lines, + } +} + +type CallbackData struct { + Command string `json:"cmd"` + Args []string `json:"args"` +} + +func NewCallbackData(command string, args ...any) *CallbackData { + stringArgs := make([]string, len(args)) + for i, arg := range args { + stringArgs[i] = fmt.Sprint(arg) + } + return &CallbackData{ + Command: command, + Args: stringArgs, + } +} +func (d *CallbackData) ToJson() string { + data, err := json.Marshal(d) + if err != nil { + return `{"cmd":""}` + } + return string(data) } diff --git a/logger.go b/logger.go deleted file mode 100644 index feb00bd..0000000 --- a/logger.go +++ /dev/null @@ -1,230 +0,0 @@ -package laniakea - -import ( - "fmt" - "os" - "path/filepath" - "runtime" - "sort" - "strings" - "time" - - "github.com/fatih/color" -) - -type LoggerWriter func(level LogLevel, prefix, traceback string, message []any) - -type Logger struct { - prefix string - level LogLevel - printTraceback bool - printTime bool - writers []LoggerWriter - - f *os.File -} - -type LogLevel struct { - n uint8 - t string - c color.Attribute -} - -func (l *LogLevel) GetName() string { - return l.t -} - -type MethodTraceback struct { - Package string - Method string - fullPath string - signature string - filename string - line int -} - -var ( - INFO = LogLevel{n: 0, t: "info", c: color.FgWhite} - WARN = LogLevel{n: 1, t: "warn", c: color.FgHiYellow} - ERROR = LogLevel{n: 2, t: "error", c: color.FgHiRed} - FATAL = LogLevel{n: 3, t: "fatal", c: color.FgRed} - DEBUG = LogLevel{n: 4, t: "debug", c: color.FgGreen} -) - -func CreateLogger() *Logger { - return &Logger{ - prefix: "LOG", - level: FATAL, - printTraceback: false, - printTime: true, - } -} - -func (l *Logger) OpenFile(name string) *Logger { - err := os.MkdirAll(filepath.Dir(name), os.ModePerm) - if err != nil { - l.Fatal(err) - } - l.f, err = os.OpenFile(name, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644) - if err != nil { - l.Fatal(err) - } - return l -} - -func (l *Logger) Prefix(prefix string) *Logger { - l.prefix = prefix - return l -} -func (l *Logger) Level(level LogLevel) *Logger { - l.level = level - return l -} -func (l *Logger) PrintTraceback(b bool) *Logger { - l.printTraceback = b - return l -} -func (l *Logger) PrintTime(b bool) *Logger { - l.printTime = b - return l -} -func (l *Logger) AddWriters(writers []LoggerWriter) *Logger { - l.writers = append(l.writers, writers...) - return l -} - -func (l *Logger) Info(m ...any) { - l.print(INFO, m) -} - -func (l *Logger) Warn(m ...any) { - l.print(WARN, m) -} - -func (l *Logger) Error(m ...any) { - l.print(ERROR, m) -} - -func (l *Logger) Fatal(m ...any) { - l.print(FATAL, m) - os.Exit(1) -} - -func (l *Logger) Debug(m ...any) { - l.print(DEBUG, m) -} - -func (l *Logger) formatTime(t time.Time) string { - return fmt.Sprintf("%02d.%02d.%02d %02d:%02d:%02d", t.Day(), t.Month(), t.Year(), t.Hour(), t.Minute(), t.Second()) -} - -func (l *Logger) getTraceback() *MethodTraceback { - caller, _, _, _ := runtime.Caller(4) - details := runtime.FuncForPC(caller) - signature := details.Name() - path, line := details.FileLine(caller) - splitPath := strings.Split(path, "/") - - splitSignature := strings.Split(signature, ".") - pkg, method := splitSignature[0], splitSignature[len(splitSignature)-1] - - tb := &MethodTraceback{ - filename: splitPath[len(splitPath)-1], - fullPath: path, - line: line, - signature: signature, - Package: pkg, - Method: method, - } - - return tb -} -func (l *Logger) formatTraceback(mt *MethodTraceback) string { - return fmt.Sprintf("%s:%s:%d", mt.filename, mt.Method, mt.line) -} - -func (l *Logger) getFullTraceback(skip int) []*MethodTraceback { - pc := make([]uintptr, 15) - runtime.Callers(skip, pc) - list := make([]*MethodTraceback, 0) - frames := runtime.CallersFrames(pc) - for { - frame, more := frames.Next() - if !more { - break - } - details := runtime.FuncForPC(frame.PC) - signature := details.Name() - path, line := details.FileLine(frame.PC) - splitPath := strings.Split(path, "/") - - splitSignature := strings.Split(signature, ".") - pkg, method := splitSignature[0], splitSignature[len(splitSignature)-1] - - tb := &MethodTraceback{ - filename: splitPath[len(splitPath)-1], - fullPath: path, - line: line, - signature: signature, - Package: pkg, - Method: method, - } - list = append(list, tb) - } - sort.Slice(list, func(i, j int) bool { - return j < i - }) - return list -} -func (l *Logger) formatFullTraceback(tracebacks []*MethodTraceback) string { - formatted := make([]string, 0) - for _, tb := range tracebacks { - formatted = append(formatted, l.formatTraceback(tb)) - } - return strings.Join(formatted, "->") -} - -func (l *Logger) buildString(level LogLevel, m []any) string { - args := []string{ - fmt.Sprintf("[%s]", l.prefix), - fmt.Sprintf("[%s]", strings.ToUpper(level.t)), - } - - if l.printTraceback { - args = append(args, fmt.Sprintf("[%s]", l.formatTraceback(l.getTraceback()))) - } - - if l.printTime { - args = append(args, fmt.Sprintf("[%s]", l.formatTime(time.Now()))) - } - - msg := Map(m, func(el any) string { - return fmt.Sprintf("%v", el) - }) - - return fmt.Sprintf("%s %v", strings.Join(args, " "), strings.Join(msg, " ")) -} - -func (l *Logger) print(level LogLevel, m []any) { - if l.level.n < level.n { - return - } - _, err := color.New(level.c).Println(l.buildString(level, m)) - if err != nil { - l.Fatal(err) - return - } - - for _, writer := range l.writers { - writer(level, l.prefix, l.formatFullTraceback(l.getFullTraceback(4)), m) - } - - if l.f != nil { - writeToFiles := os.Getenv("WRITE_TO_FILE") - if writeToFiles != "false" { - if _, err := l.f.Write([]byte(l.buildString(level, m) + "\n")); err != nil { - l.Fatal(err) - } - } - } -} diff --git a/methods.go b/methods.go index 2793a48..f80cfa7 100644 --- a/methods.go +++ b/methods.go @@ -1,11 +1,14 @@ package laniakea -import "fmt" +import ( + "encoding/json" + "fmt" +) -var NO_PARAMS = make(map[string]interface{}) +var NoParams = make(map[string]any) func (b *Bot) Updates() ([]*Update, error) { - params := make(map[string]interface{}) + params := make(map[string]any) params["offset"] = b.updateOffset params["timeout"] = 30 params["allowed_updates"] = b.updateTypes @@ -15,32 +18,32 @@ func (b *Bot) Updates() ([]*Update, error) { return nil, err } res := make([]*Update, 0) - for _, u := range data["data"].([]interface{}) { - updateObj := new(Update) - err = MapToStruct(u.(map[string]interface{}), updateObj) + err = AnyToStruct(data["data"], &res) + if err != nil { + return res, err + } + + for _, u := range res { + b.updateOffset = u.UpdateID + 1 + err = b.updateQueue.Enqueue(u) if err != nil { return res, err } - b.updateOffset = updateObj.UpdateID + 1 - err = b.updateQueue.Enqueue(updateObj) - if err != nil { - return res, err - } - res = append(res, updateObj) + res = append(res, u) if b.debug && b.requestLogger != nil { - j, err := MapToJson(u.(map[string]interface{})) + j, err := json.Marshal(u) if err != nil { b.logger.Error(err) } - b.requestLogger.Debug(fmt.Sprintf("UPDATE %s", j)) + b.requestLogger.Debugln(fmt.Sprintf("UPDATE %s", j)) } } return res, err } func (b *Bot) GetMe() (*User, error) { - data, err := b.request("getMe", NO_PARAMS) + data, err := b.request("getMe", NoParams) if err != nil { return nil, err } @@ -50,28 +53,23 @@ func (b *Bot) GetMe() (*User, error) { } type SendMessageP struct { - BusinessConnectionID string `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id"` - MessageThreadID int `json:"message_thread_id,omitempty"` - Text string `json:"text"` - ParseMode ParseMode `json:"parse_mode,omitempty"` - Entities []*MessageEntity `json:"entities,omitempty"` - LinkPreviewOptions *LinkPreviewOptions `json:"link_preview_options,omitempty"` - DisableNotifications bool `json:"disable_notifications,omitempty"` - ProtectContent bool `json:"protect_content,omitempty"` - AllowPaidBroadcast bool `json:"allow_paid_broadcast,omitempty"` - MessageEffectID string `json:"message_effect_id,omitempty"` - ReplyParameters *ReplyParameters `json:"reply_parameters,omitempty"` - InlineKeyboardMarkup *InlineKeyboardMarkup `json:"reply_markup,omitempty"` - // ReplyKeyboardMarkup *ReplyKeyboardMarkup `json:"reply_markup,omitempty"` + BusinessConnectionID string `json:"business_connection_id,omitempty"` + ChatID int `json:"chat_id"` + MessageThreadID int `json:"message_thread_id,omitempty"` + ParseMode ParseMode `json:"parse_mode,omitempty"` + Text string `json:"text"` + Entities []*MessageEntity `json:"entities,omitempty"` + LinkPreviewOptions *LinkPreviewOptions `json:"link_preview_options,omitempty"` + DisableNotifications bool `json:"disable_notifications,omitempty"` + ProtectContent bool `json:"protect_content,omitempty"` + AllowPaidBroadcast bool `json:"allow_paid_broadcast,omitempty"` + MessageEffectID string `json:"message_effect_id,omitempty"` + ReplyParameters *ReplyParameters `json:"reply_parameters,omitempty"` + ReplyMarkup InlineKeyboardMarkup `json:"reply_markup,omitempty"` } func (b *Bot) SendMessage(params *SendMessageP) (*Message, error) { - dataP, err := StructToMap(params) - if err != nil { - return nil, err - } - data, err := b.request("sendMessage", dataP) + data, err := b.request("sendMessage", params) if err != nil { return nil, err } @@ -81,27 +79,79 @@ func (b *Bot) SendMessage(params *SendMessageP) (*Message, error) { } type SendPhotoP struct { - BusinessConnectionID string `json:"business_connection_id,omitempty"` - ChatID int `json:"chat_id"` - MessageThreadID int `json:"message_thread_id,omitempty"` - Photo string `json:"photo"` - Caption string `json:"caption,omitempty"` - ParseMode ParseMode `json:"parse_mode,omitempty"` - CaptionEntities []*MessageEntity `json:"caption_entities,omitempty"` - ShowCaptionAboveMedia bool `json:"show_caption_above_media"` - HasSpoiler bool `json:"has_spoiler"` - DisableNotifications bool `json:"disable_notifications,omitempty"` - ProtectContent bool `json:"protect_content,omitempty"` - AllowPaidBroadcast bool `json:"allow_paid_broadcast,omitempty"` - MessageEffectID string `json:"message_effect_id,omitempty"` + BusinessConnectionID string `json:"business_connection_id,omitempty"` + ChatID int `json:"chat_id"` + MessageThreadID int `json:"message_thread_id,omitempty"` + ParseMode ParseMode `json:"parse_mode,omitempty"` + Photo string `json:"photo"` + Caption string `json:"caption,omitempty"` + CaptionEntities []*MessageEntity `json:"caption_entities,omitempty"` + ShowCaptionAboveMedia bool `json:"show_caption_above_media"` + HasSpoiler bool `json:"has_spoiler"` + DisableNotifications bool `json:"disable_notifications,omitempty"` + ProtectContent bool `json:"protect_content,omitempty"` + AllowPaidBroadcast bool `json:"allow_paid_broadcast,omitempty"` + MessageEffectID string `json:"message_effect_id,omitempty"` + ReplyMarkup InlineKeyboardMarkup `json:"reply_markup,omitempty"` } func (b *Bot) SendPhoto(params *SendPhotoP) (*Message, error) { - dataP, err := StructToMap(params) - if err != nil { - return nil, err - } - data, err := b.request("sendPhoto", dataP) + data, err := b.request("sendPhoto", params) + if err != nil { + return nil, err + } + message := new(Message) + err = MapToStruct(data, message) + return message, err +} + +type EditMessageTextP struct { + BusinessConnectionID string `json:"business_connection_id,omitempty"` + ChatID int `json:"chat_id,omitempty"` + MessageID int `json:"message_id,omitempty"` + InlineMessageID string `json:"inline_message_id,omitempty"` + Text string `json:"text"` + ParseMode ParseMode `json:"parse_mode,omitempty"` + ReplyMarkup InlineKeyboardMarkup `json:"reply_markup,omitempty"` +} + +func (b *Bot) EditMessageText(params *EditMessageTextP) (*Message, error) { + data, err := b.request("editMessageText", params) + if err != nil { + return nil, err + } + message := new(Message) + err = MapToStruct(data, message) + return message, err +} + +type EditMessageCaptionP struct { + BusinessConnectionID string `json:"business_connection_id,omitempty"` + ChatID int `json:"chat_id,omitempty"` + MessageID int `json:"message_id,omitempty"` + InlineMessageID string `json:"inline_message_id,omitempty"` + Caption string `json:"caption"` + ParseMode ParseMode `json:"parse_mode,omitempty"` + ReplyMarkup InlineKeyboardMarkup `json:"reply_markup,omitempty"` +} + +func (b *Bot) EditMessageCaption(params *EditMessageCaptionP) (*Message, error) { + data, err := b.request("editMessageCaption", params) + if err != nil { + return nil, err + } + message := new(Message) + err = MapToStruct(data, message) + return message, err +} + +type DeleteMessageP struct { + ChatID int `json:"chat_id"` + MessageID int `json:"message_id"` +} + +func (b *Bot) DeleteMessage(params *DeleteMessageP) (*Message, error) { + data, err := b.request("deleteMessage", params) if err != nil { return nil, err } diff --git a/msg_context.go b/msg_context.go new file mode 100644 index 0000000..441ee6a --- /dev/null +++ b/msg_context.go @@ -0,0 +1,164 @@ +package laniakea + +import "fmt" + +type MsgContext struct { + Bot *Bot + Msg *Message + Update *Update + From *User + CallbackMsgId int + FromID int + Prefix string + Text string + Args []string +} + +type AnswerMessage struct { + MessageID int + Text string + IsMedia bool + Keyboard *InlineKeyboard + ctx *MsgContext +} + +func (ctx *MsgContext) edit(messageId int, text string, keyboard *InlineKeyboard) *AnswerMessage { + params := &EditMessageTextP{ + MessageID: messageId, + ChatID: ctx.Msg.Chat.ID, + Text: text, + ParseMode: ParseMD, + } + if keyboard != nil { + params.ReplyMarkup = keyboard.Get() + } + msg, err := ctx.Bot.EditMessageText(params) + if err != nil { + ctx.Bot.logger.Errorln(err) + return nil + } + return &AnswerMessage{ + MessageID: msg.MessageID, ctx: ctx, Text: text, IsMedia: false, + } +} +func (m *AnswerMessage) Edit(text string) *AnswerMessage { + return m.ctx.edit(m.MessageID, text, nil) +} +func (ctx *MsgContext) EditCallback(text string, keyboard *InlineKeyboard) *AnswerMessage { + if ctx.CallbackMsgId == 0 { + ctx.Bot.logger.Errorln("Can't edit non-callback update message") + return nil + } + + return ctx.edit(ctx.CallbackMsgId, text, keyboard) +} +func (ctx *MsgContext) EditCallbackf(format string, keyboard *InlineKeyboard, args ...any) *AnswerMessage { + return ctx.EditCallback(fmt.Sprintf(format, args...), keyboard) +} + +func (ctx *MsgContext) editPhotoText(messageId int, text string, kb *InlineKeyboard) *AnswerMessage { + params := &EditMessageCaptionP{ + ChatID: ctx.Msg.Chat.ID, + MessageID: messageId, + Caption: text, + ParseMode: ParseMD, + } + if kb != nil { + params.ReplyMarkup = kb.Get() + } + msg, err := ctx.Bot.EditMessageCaption(params) + if err != nil { + ctx.Bot.logger.Errorln(err) + } + return &AnswerMessage{ + MessageID: msg.MessageID, ctx: ctx, Text: text, IsMedia: true, + } +} +func (m *AnswerMessage) EditCaption(text string) *AnswerMessage { + return m.ctx.editPhotoText(m.MessageID, text, nil) +} +func (m *AnswerMessage) EditCaptionKeyboard(text string, kb *InlineKeyboard) *AnswerMessage { + return m.ctx.editPhotoText(m.MessageID, text, kb) +} + +func (ctx *MsgContext) answer(text string, keyboard *InlineKeyboard) *AnswerMessage { + params := &SendMessageP{ + ChatID: ctx.Msg.Chat.ID, + Text: text, + ParseMode: ParseMD, + } + if keyboard != nil { + params.ReplyMarkup = keyboard.Get() + } + + msg, err := ctx.Bot.SendMessage(params) + if err != nil { + ctx.Bot.logger.Errorln(err) + return nil + } + return &AnswerMessage{ + MessageID: msg.MessageID, ctx: ctx, IsMedia: false, Text: text, + } +} +func (ctx *MsgContext) Answer(text string) *AnswerMessage { + return ctx.answer(text, nil) +} +func (ctx *MsgContext) Answerf(template string, args ...any) *AnswerMessage { + return ctx.answer(fmt.Sprintf(template, args...), nil) +} +func (ctx *MsgContext) Keyboard(text string, kb *InlineKeyboard) *AnswerMessage { + return ctx.answer(text, kb) +} + +func (ctx *MsgContext) answerPhoto(photoId, text string, kb *InlineKeyboard) *AnswerMessage { + params := &SendPhotoP{ + ChatID: ctx.Msg.Chat.ID, + Caption: text, + Photo: photoId, + ParseMode: ParseMD, + } + if kb != nil { + params.ReplyMarkup = kb.Get() + } + msg, err := ctx.Bot.SendPhoto(params) + if err != nil { + ctx.Bot.logger.Errorln(err) + } + return &AnswerMessage{ + MessageID: msg.MessageID, ctx: ctx, Text: text, IsMedia: true, + } +} +func (ctx *MsgContext) AnswerPhoto(photoId, text string) *AnswerMessage { + return ctx.answerPhoto(photoId, text, nil) +} +func (ctx *MsgContext) AnswerPhotoKeyboard(photoId, text string, kb *InlineKeyboard) *AnswerMessage { + return ctx.answerPhoto(photoId, text, kb) +} + +func (ctx *MsgContext) delete(messageId int) { + _, err := ctx.Bot.DeleteMessage(&DeleteMessageP{ + ChatID: ctx.Msg.Chat.ID, + MessageID: messageId, + }) + if err != nil { + ctx.Bot.logger.Errorln(err) + } +} +func (m *AnswerMessage) Delete() { + m.ctx.delete(m.MessageID) +} +func (ctx *MsgContext) CallbackDelete() { + ctx.delete(ctx.CallbackMsgId) +} + +func (ctx *MsgContext) Error(err error) { + _, sendErr := ctx.Bot.SendMessage(&SendMessageP{ + ChatID: ctx.Msg.Chat.ID, + Text: fmt.Sprintf(ctx.Bot.errorTemplate, EscapeMarkdown(err.Error())), + }) + ctx.Bot.logger.Errorln(err) + + if sendErr != nil { + ctx.Bot.logger.Errorln(sendErr) + } +} diff --git a/plugins.go b/plugins.go index 29f5c27..eb56574 100644 --- a/plugins.go +++ b/plugins.go @@ -1,5 +1,7 @@ package laniakea +import "log" + type CommandExecutor func(ctx *MsgContext, dbContext *DatabaseContext) type PluginBuilder struct { @@ -44,8 +46,8 @@ func (p *PluginBuilder) UpdateListener(listener CommandExecutor) *PluginBuilder } func (p *PluginBuilder) Build() *Plugin { - if len(p.commands) == 0 { - return nil + if len(p.commands) == 0 && len(p.payloads) == 0 { + log.Println("no command or payloads") } plugin := &Plugin{ Name: p.name, diff --git a/queue.go b/queue.go index 69f6020..3789918 100644 --- a/queue.go +++ b/queue.go @@ -5,14 +5,14 @@ import ( "sync" ) +var QueueFullErr = errors.New("queue is full") + type Queue[T any] struct { size uint64 mu sync.RWMutex queue []T } -var QueueFullError = errors.New("queue full") - func CreateQueue[T any](size uint64) *Queue[T] { return &Queue[T]{ queue: make([]T, 0), @@ -22,7 +22,7 @@ func CreateQueue[T any](size uint64) *Queue[T] { func (q *Queue[T]) Enqueue(el T) error { if q.IsFull() { - return QueueFullError + return QueueFullErr } q.queue = append(q.queue, el) return nil diff --git a/runners.go b/runners.go new file mode 100644 index 0000000..75daaa2 --- /dev/null +++ b/runners.go @@ -0,0 +1,80 @@ +package laniakea + +import ( + "time" +) + +type RunnerFn func(*Bot) error +type RunnerBuilder struct { + name string + onetime bool + async bool + timeout time.Duration + fn RunnerFn +} +type Runner struct { + Name string + Onetime bool + Async bool + Timeout time.Duration + Fn RunnerFn +} + +func NewRunner(name string, fn RunnerFn) *RunnerBuilder { + return &RunnerBuilder{ + name: name, fn: fn, async: true, + } +} +func (b *RunnerBuilder) Onetime(onetime bool) *RunnerBuilder { + b.onetime = onetime + return b +} +func (b *RunnerBuilder) Async(async bool) *RunnerBuilder { + b.async = async + return b +} +func (b *RunnerBuilder) Timeout(timeout time.Duration) *RunnerBuilder { + b.timeout = timeout + return b +} +func (b *RunnerBuilder) Build() Runner { + return Runner{ + Name: b.name, Onetime: b.onetime, Async: b.async, Fn: b.fn, Timeout: b.timeout, + } +} + +func (b *Bot) ExecRunners() { + for _, runner := range b.runners { + if !runner.Onetime && !runner.Async { + b.logger.Warnf("Runner %s not onetime, but sync\n", runner.Name) + continue + } + if !runner.Onetime && runner.Async && runner.Timeout == (time.Second*0) { + b.logger.Warnf("Background runner \"%s\" should have timeout", runner.Name) + } + + if runner.Async && runner.Onetime { + go func() { + err := runner.Fn(b) + if err != nil { + b.logger.Warnf("Runner %s failed: %s\n", runner.Name, err) + } + }() + } else if !runner.Async && runner.Onetime { + err := runner.Fn(b) + if err != nil { + b.logger.Warnf("Runner %s failed: %s\n", runner.Name, err) + } + } else if !runner.Onetime { + go func() { + for { + err := runner.Fn(b) + if err != nil { + b.logger.Warnf("Runner %s failed: %s\n", runner.Name, err) + } + time.Sleep(runner.Timeout) + } + }() + } + } +} diff --git a/types.go b/types.go index 1a79e4b..f2fa3b7 100644 --- a/types.go +++ b/types.go @@ -12,9 +12,9 @@ type Update struct { DeletedBusinessMessage *Message `json:"deleted_business_messages,omitempty"` MessageReaction *MessageReactionUpdated `json:"message_reaction,omitempty"` MessageReactionCount *MessageReactionCountUpdated `json:"message_reaction_count,omitempty"` + CallbackQuery *CallbackQuery `json:"callback_query,omitempty"` InlineQuery int ChosenInlineResult int - CallbackQuery *CallbackQuery `json:"callback_query,omitempty"` } type User struct { @@ -43,6 +43,10 @@ type Chat struct { IsForum bool `json:"is_forum,omitempty"` } +type MessageReplyMarkup struct { + InlineKeyboard [][]InlineKeyboardButton `json:"inline_keyboard"` +} + type Message struct { MessageID int `json:"message_id"` MessageThreadID int `json:"message_thread_id,omitempty"` @@ -53,6 +57,8 @@ type Message struct { Photo []*PhotoSize `json:"photo,omitempty"` Caption string `json:"caption,omitempty"` ReplyToMessage *Message `json:"reply_to_message"` + + ReplyMarkup *MessageReplyMarkup `json:"reply_markup,omitempty"` } type InaccessableMessage struct { @@ -62,8 +68,6 @@ type InaccessableMessage struct { } type MaybeInaccessibleMessage struct { - Message - InaccessableMessage } type MessageEntity struct { @@ -103,7 +107,7 @@ type LinkPreviewOptions struct { } type InlineKeyboardMarkup struct { - InlineKeyboard [][]*InlineKeyboardButton `json:"inline_keyboard"` + InlineKeyboard [][]InlineKeyboardButton `json:"inline_keyboard,omitempty"` } type InlineKeyboardButton struct { @@ -117,9 +121,9 @@ type ReplyKeyboardMarkup struct { } type CallbackQuery struct { - ID string `json:"id"` - From *User `json:"user"` - Message *MaybeInaccessibleMessage `json:"message"` + ID string `json:"id"` + From *User `json:"from"` + Message *Message `json:"message"` Data string `json:"data"` } diff --git a/utils.go b/utils.go index c16334f..b071e13 100644 --- a/utils.go +++ b/utils.go @@ -1,13 +1,38 @@ package laniakea -import "encoding/json" +import ( + "encoding/json" + "fmt" + "strings" +) -func MapToStruct(m map[string]any, dst interface{}) error { +// MapToStruct unsafe function +func MapToStruct(m map[string]any, s any) error { data, err := json.Marshal(m) if err != nil { return err } - err = json.Unmarshal(data, dst) + err = json.Unmarshal(data, s) + return err +} + +// SliceToStruct unsafe function +func SliceToStruct(sl []any, s any) error { + data, err := json.Marshal(sl) + if err != nil { + return err + } + err = json.Unmarshal(data, s) + return err +} + +// AnyToStruct unsafe function +func AnyToStruct(src, dest any) error { + data, err := json.Marshal(src) + if err != nil { + return err + } + err = json.Unmarshal(data, dest) return err } @@ -16,12 +41,12 @@ func MapToJson(m map[string]any) (string, error) { return string(data), err } -func StructToMap(s interface{}) (map[string]any, error) { +func StructToMap(s interface{}) (map[string]interface{}, error) { data, err := json.Marshal(s) if err != nil { return nil, err } - m := make(map[string]any) + m := make(map[string]interface{}) err = json.Unmarshal(data, &m) return m, err } @@ -33,3 +58,72 @@ func Map[T, V any](ts []T, fn func(T) V) []V { } return result } + +func EscapeMarkdown(s string) string { + s = strings.ReplaceAll(s, "_", `\_`) + s = strings.ReplaceAll(s, "*", `\*`) + s = strings.ReplaceAll(s, "[", `\[`) + return strings.ReplaceAll(s, "`", "\\`") +} + +func EscapeMarkdownV2(s string) string { + symbols := []string{"_", "*", "[", "]", "(", ")", "~", "`", ">", "#", "+", "-", "=", "|", "{", "}", ".", "!"} + for _, symbol := range symbols { + s = strings.ReplaceAll(s, symbol, fmt.Sprintf("\\%s", symbol)) + } + return s +} + +func GetUnclosedTag(markdown string) string { + // order is important! + var tags = []string{ + "```", + "`", + "*", + "_", + } + var currentTag = "" + + markdownRunes := []rune(markdown) + + var i = 0 +outer: + for i < len(markdownRunes) { + // skip escaped characters (only outside tags) + if markdownRunes[i] == '\\' && currentTag == "" { + i += 2 + continue + } + if currentTag != "" { + if strings.HasPrefix(string(markdownRunes[i:]), currentTag) { + // turn a tag off + i += len(currentTag) + currentTag = "" + continue + } + } else { + for _, tag := range tags { + if strings.HasPrefix(string(markdownRunes[i:]), tag) { + // turn a tag on + currentTag = tag + i += len(currentTag) + continue outer + } + } + } + i++ + } + + return currentTag +} +func IsValid(markdown string) bool { + return GetUnclosedTag(markdown) == "" +} + +func FixMarkdown(markdown string) string { + tag := GetUnclosedTag(markdown) + if tag == "" { + return markdown + } + return markdown + tag +} diff --git a/version.go b/version.go index d47af22..aa6cbec 100644 --- a/version.go +++ b/version.go @@ -1,12 +1,8 @@ package laniakea -import "os" - const ( - VersionString = "0.1.4" + VersionString = "0.3.0" VersionMajor = 0 - VersionMinor = 1 - VersionPatch = 4 + VersionMinor = 3 + VersionPatch = 0 ) - -var GoVersion = os.Getenv("GoV")