From 50bcb375139468287e093716a1494afc7a7a647a Mon Sep 17 00:00:00 2001 From: maximo tejeda Date: Sat, 8 Feb 2025 14:01:05 -0400 Subject: [PATCH] FIRST COMMIT In the beginning there was darkness --- .dockerignore | 1 + .gitignore | 6 + Dockerfile | 21 ++ Makefile | 66 +++++ Readme.org | 117 ++++++++ cmd/bot/main.go | 104 ++++++++ cmd/webapp/main.go | 54 ++++ config/config.go | 68 +++++ go.mod | 17 ++ go.sum | 36 +++ internal/adapters/db/db.go | 1 + internal/adapters/grpc/grpc.go | 1 + internal/adapters/grpc/tgbuser/tgb.go | 246 +++++++++++++++++ internal/adapters/nats/nats.go | 1 + internal/application/api/api.go | 16 ++ internal/application/commands/commands.go | 66 +++++ internal/application/domains/models.go | 1 + internal/application/domains/users.go | 13 + internal/application/helpers/auth.go | 202 ++++++++++++++ internal/application/helpers/file.go | 24 ++ internal/application/helpers/keyboard.go | 139 ++++++++++ internal/application/helpers/photo.go | 27 ++ internal/application/messages/messages.go | 32 +++ internal/application/messages/reactions.go | 61 +++++ .../application/middlewares/middlewares.go | 252 ++++++++++++++++++ internal/application/queries/queries.go | 43 +++ internal/ports/ports.go | 1 + internal/ports/user.go | 27 ++ webapp/#index.html# | 20 ++ webapp/index.html | 20 ++ webapp/static/css/#index.css# | 0 webapp/static/css/index.css | 0 webapp/static/html/index.html | 20 ++ webapp/static/js/index.js | 0 webapp/template/index.html.template | 0 35 files changed, 1703 insertions(+) create mode 100644 .dockerignore create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 Makefile create mode 100644 Readme.org create mode 100644 cmd/bot/main.go create mode 100644 cmd/webapp/main.go create mode 100644 config/config.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/adapters/db/db.go create mode 100644 internal/adapters/grpc/grpc.go create mode 100644 internal/adapters/grpc/tgbuser/tgb.go create mode 100644 internal/adapters/nats/nats.go create mode 100644 internal/application/api/api.go create mode 100644 internal/application/commands/commands.go create mode 100644 internal/application/domains/models.go create mode 100644 internal/application/domains/users.go create mode 100644 internal/application/helpers/auth.go create mode 100644 internal/application/helpers/file.go create mode 100644 internal/application/helpers/keyboard.go create mode 100644 internal/application/helpers/photo.go create mode 100644 internal/application/messages/messages.go create mode 100644 internal/application/messages/reactions.go create mode 100644 internal/application/middlewares/middlewares.go create mode 100644 internal/application/queries/queries.go create mode 100644 internal/ports/ports.go create mode 100644 internal/ports/user.go create mode 100644 webapp/#index.html# create mode 100644 webapp/index.html create mode 100644 webapp/static/css/#index.css# create mode 100644 webapp/static/css/index.css create mode 100644 webapp/static/html/index.html create mode 100644 webapp/static/js/index.js create mode 100644 webapp/template/index.html.template diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2eea525 --- /dev/null +++ b/.dockerignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7124de4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.env +k8s/deployment.yml +bin/ +dolardb/ + +/icon.png diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..75878b8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,21 @@ +FROM golang:alpine AS builder +ARG TARGETARCH +ARG version=not-set +ARG SHORTSHA=not-set +WORKDIR /app +COPY . . +RUN apk --no-cache add git +# https://stackoverflow.com/questions/70369368/check-architecture-in-dockerfile-to-get-amd-arm +RUN go build -o bin/bot \ + -ldflags "-X main.Shortsha=${SHORTSHA} \ + -X main.Version=${version} \ + -X main.Aarch=${TARGETARCH}" ./cmd/bot/main.go + +FROM alpine AS runner +COPY --from=builder /app/bin/bot /usr/bin/ +WORKDIR /app +RUN apk --no-cache add --no-check-certificate ca-certificates \ + && update-ca-certificates +RUN apk add --no-cache tzdata + +ENTRYPOINT /usr/bin/bot diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6ab2ef2 --- /dev/null +++ b/Makefile @@ -0,0 +1,66 @@ +# must create a .env file with info +# must have compose installed +include .env +export +OS:=${shell go env GOOS} +ARCH=$(shell go env GOARCH) +OOSS="linux" +ARRCHS="arm 386" +DEBUG=1 +SERVICE=bot +BINAME=$(SERVICE)-$(OS)-$(ARCH) +BINAMEARM=$(SERVICE)-$(OS)-arm64 +# can be docker or podman or whatever +CONTAINERS=docker +COMPOSE=$(CONTAINERS)-compose +# Configure local registry +REGADDR=10.0.0.150 +K8SRSNAME=$(shell kubectl get rs --no-headers -o custom-columns=":metadata.name" | grep us-dop-bot) +.phony: all clean build test clean-image build-image build-image-debug run-image run-image-debug run-local + + +build-image: build +# here we made the images and push to registry with buildx + @$(CONTAINERS) buildx build --build-arg="BINAME=$(BINAMEARM)" --platform linux/arm64 --push -t $(REGADDR)/us-dop-bot:latest . + +# Here we upload it to local + +build-test-image: + @$(CONTAINERS) buildx build --platform linux/arm64 --push -t $(REGADDR)/us-dop-bot:latest . + +run-image: build-image + @$(CONTAINERS) compose -f docker-compose.yaml up + +build-image-debug: clean + @$(CONTAINERS) compose -f docker-compose-debug.yaml build + +run-image-debug: build-image-debug + @$(CONTAINERS) compose -f docker-compose-debug.yaml up + +run-local:clean build + @bin/$(BINAME) + +run-webapp: clean build + @bin/$(BINAME)-webapp + +build: clean + @go build -o ./bin/$(BINAME) ./cmd/bot/. + @go build -o ./bin/$(BINAME)-webapp ./cmd/webapp/. + +create-descriptors: + @envsubst < k8s/deployment.yml.template > k8s/deployment.yml + +deploy: build-image create-descriptors + @kubectl apply -f k8s/deployment.yml + @kubectl scale rs $(K8SRSNAME) --replicas=0 + @kubectl scale rs $(K8SRSNAME) --replicas=1 + +test: + @go -count=1 test ./... +clean: + @rm -rf ./bin + +clean-image: + @$(CONTAINERS) system prune -f + + diff --git a/Readme.org b/Readme.org new file mode 100644 index 0000000..2099009 --- /dev/null +++ b/Readme.org @@ -0,0 +1,117 @@ +#+Author: Maximo Tejeda +#+Email: root@maximotejeda.com +#+Date: 10/10/2024 + +* Telegram Bot Repository Template + +This README serves as a guide and template for setting up new Telegram bot projects. Follow the +instructions below to get your bot up and running. + +** Table of Contents + +- [[#introduction][Introduction]] +- [[#prerequisites][Prerequisites]] +- [[#setup-instructions][Setup Instructions]] + - [[#clone-the-repository][Clone the Repository]] + - [[#install-dependencies][Install Dependencies]] + - [[#configure-your-bot][Configure Your Bot]] +- [[#running-the-bot][Running the Bot]] +- [[#Description][Description]] +** Introduction +<> +This repository contains a basic template for developing Telegram bots. It includes essential +scripts, configuration files, and documentation to help you get started quickly. + +** Prerequisites +<> + +Before setting up your bot, ensure you have the following installed: + +- *go*: The go programming for running go code. +- *Telegram Bot Token*: Obtain it by creating a new bot via [[https://t.me/botfather][BotFather]] on + Telegram. + +** Setup Instructions +<> + +*** Clone the Repository + +To get started, clone this repository to your local machine: +#+BEGIN_SRC bash +git clone https://github.com/yourusername/telegram-bot-template.git +cd telegram-bot-template +#+END_SRC + +*** Install Dependencies + +Install all required dependencies using go tools: +#+BEGIN_SRC bash +go mod tidy +#+END_SRC + +*** Configure Your Bot + +Create a ~.env~ file in the root directory and add your Telegram bot token: +#+BEGIN_SRC plaintext +#.env file example + +BOT_TOKEN=yout_bot_token_goes_here +NATS_SERVICE_URL=natsuri:4222 +TGBUSER_SERVICE_URL=localhost:3001 // needed to start svc +ENV=development // define log level on start +ADMINS=admin_ids_comma_separated // id for admins, those will not need auth +RATE_LIMIT_SEC=12 // amount of time to limit hits in sec +RATE_LIMIT_AMOUNT=2 // amount of hit limits in x times + +#+END_SRC + +** Running the Bot +<> + +To start the bot, execute one of the following commands + +If the .env Vars ar e in the environment is dafe to run: +#+BEGIN_SRC bash +go run cmd/bot/. +#+END_SRC + +Else you can run though Makefile with target run-local + +The Makefile populate .env, build and run the source code, on each +run the clean target will be called. +#+begin_src bash + make run-local +#+end_src + +Your bot should now be running locally. + +** Description +<> + +This is a template Telegram bot designed to be shared across various +projects. It includes ready-to-use handlers for commands or queries, +serving as examples to simplify development. The bot is built using +the current version of the [[https://github.com/go-telegram/bot][go-telegram/bot]] library, ensuring +up-to-date functionality and integration. + +In the functions added for the template you can find things like: +- Message Handlers +- Command Handlers +- Callback Query Handlers +- Interactions Handlers +- Middlewares + - Singleflight + - Loging + - Rate Limiting + - Auth Func +- Helpers + - Auth + - File + - Photo + - Keyboard + +This template heavily relies on the **[[tgbuser]]** microservice for +handling user authentication and permissions. + +As The current date theres still a TODO on the WebApp part of the bot +for serving and templating an example for miniApps diff --git a/cmd/bot/main.go b/cmd/bot/main.go new file mode 100644 index 0000000..daee055 --- /dev/null +++ b/cmd/bot/main.go @@ -0,0 +1,104 @@ +package main + +import ( + "context" + "log/slog" + "os" + "os/signal" + "strings" + + "git.maximotejeda.com/maximo/telegram-base-bot/config" + "git.maximotejeda.com/maximo/telegram-base-bot/internal/adapters/grpc/tgbuser" + "git.maximotejeda.com/maximo/telegram-base-bot/internal/application/commands" + "git.maximotejeda.com/maximo/telegram-base-bot/internal/application/messages" + "git.maximotejeda.com/maximo/telegram-base-bot/internal/application/middlewares" + "git.maximotejeda.com/maximo/telegram-base-bot/internal/application/queries" + "git.maximotejeda.com/maximo/telegram-base-bot/internal/ports" + "github.com/go-telegram/bot" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + + +var log *slog.Logger + +func main() { + lvelEnv:= config.GetEnvironment() + var lvel slog.Level + if lvelEnv == "dev" || lvelEnv == "development"{ + lvel = slog.LevelDebug + }else { + lvel = slog.LevelInfo + } + log = slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ + AddSource: true, + Level: lvel, + })) + + ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt) + defer cancel() + userSVC, conn := CreateAdaptersGRPC() + defer conn.Close() + authRequired := middlewares.SetAuthRequired(userSVC, log) + logMD := middlewares.CreateLogMiddleWare(ctx, log) + opts := []bot.Option{ + bot.WithMiddlewares(logMD, authRequired), + bot.WithAllowedUpdates(bot.AllowedUpdates{ + "message", + "edited_message", + "message_reaction", + "message_reaction_count", + "callback_query", + "id", + }), + // bot.WithDefaultHandler(api.Handler), + } + b, err := bot.New(config.GetToken(), opts...) + if err != nil { + panic(err) + } + bInfo , err := b.GetMe(ctx) + if err != nil { + panic(err) + } + + // attempt to add bot to db + err = userSVC.CreateBot(bInfo.Username) + if err != nil { + // want to fail fast in case of creating and svc not available + // if i cant auth i dont want to run + if strings.Contains(err.Error(), "rpc error: code = Unavailable desc = connection error: desc") { + panic(err) + } + } + + commands.RegisterCommands(ctx, log, b) + messages.RegisterMessageHandler(ctx, log, b) + queries.RegisterQueries(ctx, log, b) + messages.RegisterMessageReactionHandler(ctx, log, b) + b.Start(ctx) +} + +// CreateAdaptersGRPC +// Create connections for service +func CreateAdaptersGRPC() (ports.UserService, *grpc.ClientConn) { + // we are outside update so we will be querying db to + // get users interested in specific updates ex bpd, brd, apa + // userID inst=> comma separated string + var opts []grpc.DialOption + opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials())) + userConn, err := grpc.NewClient(config.GetUserServiceURL(), opts...) + if err != nil { + log.Error("creating grpc conn", "error", err) + panic(err) + } + + log.Info("success creating conn", "error", err) + user, err := tgbuser.NewAdapter(userConn) + if err != nil { + log.Error("creating service adapter", "error", err) + panic(err) + } + log.Info("success creating svc ", "error", err) + return user, userConn +} diff --git a/cmd/webapp/main.go b/cmd/webapp/main.go new file mode 100644 index 0000000..066dfa2 --- /dev/null +++ b/cmd/webapp/main.go @@ -0,0 +1,54 @@ +// Simple server to serve static template files for webapp +// telegram miniapp let the webapp work with bot comunicating info +package main + +import ( + "context" + "fmt" + "log/slog" + "net/http" + "os" + + "git.maximotejeda.com/maximo/telegram-base-bot/config" +) + +var ( + log *slog.Logger + lv slog.Level +) + +func main() { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + e := config.GetEnvironment() + switch e { + case "dev", "development": + lv = slog.LevelDebug + case "prod", "production": + lv = slog.LevelInfo + default: + panic(fmt.Errorf("env variable not recognized")) + } + + log = slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{ + Level: lv, + })) + + mux := http.NewServeMux() + + mux.Handle("GET /public/", http.StripPrefix("/public/", http.HandlerFunc(serveStaticFiles))) + mux.HandleFunc("GET /{$}", serveStaticFiles) + if err := http.ListenAndServe(":8081", mux); err != nil { + panic(err) + } + fmt.Println("webapp", ctx) +} + +// serveStaticFiles +func serveStaticFiles(w http.ResponseWriter, r *http.Request) { + fs := http.Dir("webapp") + h := http.FileServer(fs) + h.ServeHTTP(w, r) + log.Debug("serving staitc file", "route", r.URL.Path) +} diff --git a/config/config.go b/config/config.go new file mode 100644 index 0000000..98fccf9 --- /dev/null +++ b/config/config.go @@ -0,0 +1,68 @@ +package config + +import ( + "log" + "os" + "strconv" +) + +// GetRateLimitSec +// Get the rate limit time amount to limit user request +// for example a user can make 1 request each sec "1 req x sec" +func GetRateLimitSec()float64{ + amntStr := getEnvVariable("RATE_LIMIT_SEC") + amnt, err := strconv.ParseFloat(amntStr, 64) + if err != nil{ + panic(err) + } + return amnt +} + +// GetRateLimitAmount +// Get the rate limit amount of request to limit +// for example an user can make 10 request each 10 secs +func GetRateLimitAmount()int64{ + amntStr := getEnvVariable("RATE_LIMIT_AMOUNT") + amnt, err := strconv.ParseInt(amntStr, 10, 64) + if err != nil{ + panic(err) + } + return amnt +} + +// GetAdminsList +// Get admin list who dont need auth +func GetAdminsList()string { + return getEnvVariable("ADMINS") +} + +// GetToken +// Get telegram auth token +func GetToken() string { + return getEnvVariable("BOT_TOKEN") +} + +// GetNatsURI +// Get nats uri for server connection +func GetNatsURI() string { + return getEnvVariable("NATS_SERVICE_URL") +} + +// GetUserServiceURL +// Get uri for user service control GRPC +func GetUserServiceURL() string { + return getEnvVariable("TGBUSER_SERVICE_URL") +} + +// GetEnvironment +// Get the environment to debug or prod +func GetEnvironment() string { + return getEnvVariable("ENV") +} + +func getEnvVariable(key string) string { + if os.Getenv(key) == "" { + log.Fatal("error getting key: ", key) + } + return os.Getenv(key) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..58f718c --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module git.maximotejeda.com/maximo/telegram-base-bot + +go 1.23.2 + +require ( + git.maximotejeda.com/maximo/tgb-user v0.0.5 + github.com/go-telegram/bot v1.12.1 + google.golang.org/grpc v1.69.2 +) + +require ( + golang.org/x/net v0.30.0 // indirect + golang.org/x/sys v0.26.0 // indirect + golang.org/x/text v0.19.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 // indirect + google.golang.org/protobuf v1.35.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..929d254 --- /dev/null +++ b/go.sum @@ -0,0 +1,36 @@ +git.maximotejeda.com/maximo/tgb-user v0.0.5 h1:OTACcjzOld9TsQHqzDqGXgdN3CqWrZ2p1ro0mUErCR0= +git.maximotejeda.com/maximo/tgb-user v0.0.5/go.mod h1:7KpTUAnwap6cp5pHRKgJygxrN3rftAdTkpCG2zJIpYI= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-telegram/bot v1.12.1 h1:2CSwMd+g71/XrmuSpvEjLtsmkfL/s63PdnLboGJQxtw= +github.com/go-telegram/bot v1.12.1/go.mod h1:i2TRs7fXWIeaceF3z7KzsMt/he0TwkVC680mvdTFYeM= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= +go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= +go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= +go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= +go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= +go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= +go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc= +go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= +go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= +go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= +golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4= +golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU= +golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo= +golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM= +golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 h1:X58yt85/IXCx0Y3ZwN6sEIKZzQtDEYaBWrDvErdXrRE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= +google.golang.org/grpc v1.69.2 h1:U3S9QEtbXC0bYNvRtcoklF3xGtLViumSYxWykJS+7AU= +google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= +google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= +google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= diff --git a/internal/adapters/db/db.go b/internal/adapters/db/db.go new file mode 100644 index 0000000..3a49c63 --- /dev/null +++ b/internal/adapters/db/db.go @@ -0,0 +1 @@ +package db diff --git a/internal/adapters/grpc/grpc.go b/internal/adapters/grpc/grpc.go new file mode 100644 index 0000000..21e034e --- /dev/null +++ b/internal/adapters/grpc/grpc.go @@ -0,0 +1 @@ +package grpc diff --git a/internal/adapters/grpc/tgbuser/tgb.go b/internal/adapters/grpc/tgbuser/tgb.go new file mode 100644 index 0000000..429f4a3 --- /dev/null +++ b/internal/adapters/grpc/tgbuser/tgb.go @@ -0,0 +1,246 @@ +package tgbuser + +import ( + "context" + "log/slog" + + "git.maximotejeda.com/maximo/telegram-base-bot/internal/application/domains" + "git.maximotejeda.com/maximo/tgb-user/proto/golang/tgbuser" + "github.com/go-telegram/bot/models" + "google.golang.org/grpc" +) + +type Adapter struct { + user tgbuser.UserManagerClient + conn *grpc.ClientConn + log *slog.Logger +} + +func NewAdapter(conn *grpc.ClientConn) (*Adapter, error) { + log := slog.Default() + log = log.With("location", "user adapter") + client := tgbuser.NewUserManagerClient(conn) + return &Adapter{user: client, conn: conn, log: log}, nil +} + +func (a *Adapter) Get(tgbid int64) (*domains.User, error) { + hr, err := a.user.Get(context.Background(), &tgbuser.GetTGBUserRequest{TgbId: tgbid}) + if err != nil { + return nil, err + } + + user := &domains.User{ + ID: hr.User.Id, + Username: hr.User.Username, + FirstName: hr.User.FirstName, + LastName: hr.User.LastName, + TguID: hr.User.TgbId, + Created: hr.User.Created, + Edited: hr.User.Edited, + } + return user, nil +} + +func (a Adapter) Create(user *models.User) (b bool, err error) { + _, err = a.user.Create(context.Background(), &tgbuser.CreateTGBUserRequest{ + User: &tgbuser.User{ + TgbId: user.ID, + FirstName: user.FirstName, + LastName: user.LastName, + Username: user.Username, + }, + }) + if err != nil { + return false, err + } + return true, nil +} + +func (a Adapter) Edit(user *models.User) (b bool, err error) { + + _, err = a.user.Edit(context.Background(), &tgbuser.EditTGBUserRequest{ + User: &tgbuser.User{ + Username: user.Username, + FirstName: user.FirstName, + LastName: user.LastName, + }, + }) + if err != nil { + return false, err + } + + return true, nil +} +func (a Adapter) Delete(tgbid int64) (b bool, err error) { + _, err = a.user.Delete(context.Background(), &tgbuser.DeleteTGBUserRequest{ + TgbId: tgbid, + }) + if err != nil { + return false, err + } + + return true, nil +} + +func (a Adapter) GetBots(tgbid int64) (s []string, err error) { + hr, err := a.user.GetBots(context.Background(), &tgbuser.GetBotsTGBUserRequest{ + TgbId: tgbid, + }) + if err != nil { + return nil, err + } + s = []string{} + + if len(hr.Bots) <= 0 { + return s, nil + } + + for _, it := range hr.Bots { + s = append(s, it.BotName) + } + return s, nil +} + +func (a Adapter) AddBot(tgbid int64, botname string) (b bool, err error) { + _, err = a.user.AddBot(context.Background(), &tgbuser.AddBotTGBUserRequest{ + TgbId: tgbid, + BotName: botname, + }) + if err != nil { + return false, err + } + return true, nil +} + +func (a Adapter) DeleteBot(tgbid int64, botname string) (b bool, err error) { + _, err = a.user.DeleteBot(context.Background(), &tgbuser.DeleteBotTGBUserRequest{ + TgbId: tgbid, + BotName: botname, + }) + if err != nil { + return false, err + } + return true, nil +} + +func (a Adapter) GetAllBotsUsers(botname string) ([]*domains.User, error) { + users, err := a.user.GetAllBotsUsers(context.Background(), &tgbuser.GetAllBotsUsersRequest{BotName: botname}) + if err != nil { + a.log.Error("get all bots users", "error", err) + return nil, err + } + a.log.Info("users", "result", users) + list := []*domains.User{} + for _, us := range users.Users { + user := &domains.User{ + ID: us.Id, + TguID: us.TgbId, + Username: us.Username, + FirstName: us.FirstName, + LastName: us.LastName, + Created: us.Created, + Edited: us.Edited, + } + list = append(list, user) + } + return list, nil +} + +func (a Adapter) CreateBot(botname string) (error){ + _, err := a.user.CreateBot(context.Background(), &tgbuser.TGBBotNameRequest{BotName: botname}) + if err != nil { + a.log.Error("creating bot", "error", err) + return err + } + return nil +} + +func (a Adapter) CreateAccessRequest(tgbID int64, botName string)(bool, error){ + req := tgbuser.TGBUserBotNameRequest{ + TgbId: tgbID, + BotName: botName, + } + r,err := a.user.CreateAccessRequest(context.Background(), &req) + if err != nil { + a.log.Error("creating access request", "error", err) + return false, err + } + return r.Response, nil +} + +func (a Adapter) GrantAccess(tgbID int64, botName string)(bool, error){ + req := tgbuser.TGBUserBotNameRequest{ + TgbId: tgbID, + BotName: botName, + } + r,err := a.user.GrantAccess(context.Background(), &req) + if err != nil { + a.log.Error("creating access request", "error", err) + return false, err + } + return r.Response, nil +} + +func (a Adapter) GetAllAccessRequest(botName string)(*tgbuser.GetAccessResponse, error){ + req := tgbuser.TGBBotNameRequest{ + BotName: botName, + } + r,err := a.user.GetAllAccessRequest(context.Background(), &req) + if err != nil { + a.log.Error("creating access request", "error", err) + return nil, err + } + return r, nil +} + +func (a Adapter) BanUser(tgbID int64, until int64, botName string)(bool, error){ + req:= tgbuser.TGBBanUserRequest{ + TgbId: tgbID, + BotName: botName, + Until: until, + } + r, err := a.user.BanUser(context.Background(), &req) + if err != nil { + a.log.Error("banning user", "error", err) + return false, err + } + return r.Response, nil +} + +func (a Adapter) UnBanUser(tgbID int64, botName string)(bool, error){ + req:= tgbuser.TGBUserBotNameRequest{ + TgbId: tgbID, + BotName: botName, + } + r, err := a.user.UnBanUser(context.Background(), &req) + if err != nil { + a.log.Error("unbaning user", "error", err) + return false, err + } + return r.Response, nil +} + +func (a Adapter) GetAllBannedUsers(botName string)(*tgbuser.GetBanResponse, error){ + req := tgbuser.TGBBotNameRequest{ + BotName: botName, + } + r, err := a.user.GetAllBannedUsers(context.Background(), &req) + if err != nil { + a.log.Error("getting all banned users", "error", err) + return nil, err + } + + return r, nil +} + +func (a Adapter)GetAccessRequest(tgbID int64) (*tgbuser.GetAccessResponse, error){ + req := tgbuser.TGBUserRequest{ + TgbId: tgbID, + } + r, err := a.user.GetAccessRequest(context.Background(), &req) + if err != nil { + a.log.Error("geting access request", "userID", tgbID,"error", err) + return nil, err + } + return r, nil +} diff --git a/internal/adapters/nats/nats.go b/internal/adapters/nats/nats.go new file mode 100644 index 0000000..40b4928 --- /dev/null +++ b/internal/adapters/nats/nats.go @@ -0,0 +1 @@ +package nats diff --git a/internal/application/api/api.go b/internal/application/api/api.go new file mode 100644 index 0000000..8791075 --- /dev/null +++ b/internal/application/api/api.go @@ -0,0 +1,16 @@ +package api + +import ( + "context" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +func Handler(ctx context.Context, b *bot.Bot, update *models.Update){ + + b.SendMessage(ctx, &bot.SendMessageParams{ + ChatID: update.Message.Chat.ID, + Text: update.Message.Text, + }) +} diff --git a/internal/application/commands/commands.go b/internal/application/commands/commands.go new file mode 100644 index 0000000..fbdb3fb --- /dev/null +++ b/internal/application/commands/commands.go @@ -0,0 +1,66 @@ +package commands + +import ( + "context" + "fmt" + "log/slog" + "os" + + "git.maximotejeda.com/maximo/telegram-base-bot/internal/application/helpers" + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +var ( + HELPSTRING =` +# Telegram Base Bot + + This is an Simple example template to + initiate bots projects with the same + basic functionalities needed common + on all prj like: + + -\ Keyboard Making + -\ Command Handler + -\ Interaction Handler + -\ Message Handler + -\ Callback Query + -\ MiddleWares: + -\ Singleflight + -\ rate limiting + -\ Login + + The service is highly dependant on + the tgb user microservice which is + in charge of hanlding the bot and + authing users and banning +` +) + + +func RegisterCommands(ctx context.Context, log *slog.Logger, b *bot.Bot){ + b.RegisterHandler(bot.HandlerTypeMessageText, "/help",bot.MatchTypeExact, HelpCommand) +} + +func HelpCommand(ctx context.Context, b *bot.Bot, update *models.Update){ + _, err := b.SendMessage(ctx, &bot.SendMessageParams{ + ChatID: update.Message.Chat.ID, + Text: fmt.Sprintf("%s", HELPSTRING), + ParseMode: models.ParseModeHTML, + }) + + if err != nil { + fmt.Println(err) + } + icon , err := os.ReadFile("icon.png") + if err != nil { + fmt.Println(err) + fmt.Println(os.Getwd()) + } + //helpers.SendDocument(ctx, b, update, []byte("hello try"), "hello.txt") + err = helpers.SendPhotos(ctx, b, update, icon, "fb logo") + if err != nil { + fmt.Println(err) + } +} + diff --git a/internal/application/domains/models.go b/internal/application/domains/models.go new file mode 100644 index 0000000..a7e332a --- /dev/null +++ b/internal/application/domains/models.go @@ -0,0 +1 @@ +package domains diff --git a/internal/application/domains/users.go b/internal/application/domains/users.go new file mode 100644 index 0000000..e631e91 --- /dev/null +++ b/internal/application/domains/users.go @@ -0,0 +1,13 @@ +package domains + +type User struct { + ID int64 + TguID int64 + Username string `json:"username"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + Subs []string + Created int64 + Edited int64 + Deleted int64 +} diff --git a/internal/application/helpers/auth.go b/internal/application/helpers/auth.go new file mode 100644 index 0000000..309a6b0 --- /dev/null +++ b/internal/application/helpers/auth.go @@ -0,0 +1,202 @@ +package helpers + +import ( + "context" + "fmt" + "log/slog" + "slices" + "strconv" + "strings" + + "git.maximotejeda.com/maximo/telegram-base-bot/config" + "git.maximotejeda.com/maximo/telegram-base-bot/internal/ports" + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +func IsUserRegistered() {} +func IsUserAuthorized() {} +func GetPendingAuthRequest() {} +func CreatePendingAuthRequest() {} +func DeletePendingAuthRequest() {} +func BanUSer() {} +func UnBanUser() {} + +func Authenticate(ctx context.Context, log *slog.Logger, b *bot.Bot, update *models.Update, uSVC ports.UserService) bool { + var ( + user models.User + + ) + // select user + if update.CallbackQuery != nil { + user = update.CallbackQuery.From + } else { + if update.MessageReaction != nil { + return true + } + user = *update.Message.From + } + // bot name + bn, _ := b.GetMe(ctx) + switch IsUserAdmin(user.ID) { + case true: + // theres an user env admin + // check if user exist on db + _, err := uSVC.Get(user.ID) + if err != nil { + log.Error("geting user", "error", err) + log.Debug("user seems to not exists") + if strings.Contains(err.Error(), "sql: no rows in result set") { + // if user does not exist create it + _, err := uSVC.Create(&user) + log.Debug("creating user") + if err != nil { + log.Error("creating new user for admnin", "err", err) + } + // add bot to user list + _, err = uSVC.AddBot(user.ID, bn.Username) + if err != nil { + log.Error("Adding bot to admin user list", "err", err) + } + } + } + return true + case false: + // user is not admin and need to be authorized + // check if user is on db + _, err := uSVC.Get(user.ID) + if err != nil { + // user need auth + log.Error("user not in db, authorization from an admin is required", "error", err) + // add user to manage it + + if strings.Contains(err.Error(), "sql: no rows in result set") { + // check if theres an access request from the same user and bot + _, err := uSVC.Create(&user) + if err != nil { + log.Error("creating new user", "user", user.ID, "error", err) + } + + // create access request + _, err = uSVC.CreateAccessRequest(user.ID, bn.Username) + if err != nil { + log.Error("creating access request for ", "user", user.ID, "error", err) + } + } + } else { + bots, err := uSVC.GetBots(user.ID) + if err != nil { + log.Error("checking bots on user access") + } + switch HasUserAccess(bots, bn.Username) { + case true: + return true + case false: + // check for banned user + buser, err := uSVC.GetAllBannedUsers(bn.Username) + if err != nil { + log.Error("error querying banned user") + } + for _, u := range buser.GetBans() { + if u.TgbId == user.ID { + b.SendMessage(ctx, &bot.SendMessageParams{ + ChatID: u.TgbId, + Text: "user access is restricted, please ask for permission", + },) + return false + } + } + ac, err := uSVC.GetAccessRequest(user.ID) + acl := []string{} + for _, val := range ac.Access { + acl = append(acl, val.BotName) + } + if slices.Contains(acl, bn.Username) { + // create access request + log.Info("Access Request found returning early", "user", user.ID, "error", err) + return false + } else { + + // create one + _, err = uSVC.CreateAccessRequest(user.ID, bn.Username) + if err != nil { + log.Error("creating access request", "err", err) + } + + } + } + } + } + // get all admins + userL, _ := GetAdminFromEnv() + // send a mesage to all admins + for _, adm := range userL { + + param := GenerateAccessRequestMessage(user, adm, b, update) + b.SendMessage(ctx, param) + } + return false +} + +// GetAdminFromEnv +// will get an env variable that is a list of tgbID comma separated +// if the user trying to enter is admin auth the user +func GetAdminFromEnv() (adminList []int64, errList []error) { + adminsStrList := config.GetAdminsList() + list := strings.Split(adminsStrList, ",") + adminList = []int64{} + errList = []error{} + for _, item := range list { + adm, err := strconv.ParseInt(item, 10, 64) + if err != nil { + err = fmt.Errorf("parsing tgb admin id: %s\n err: %w", item, err) + fmt.Println(err) + errList = append(errList, err) + continue + } + adminList = append(adminList, adm) + } + return +} + +// IsUserAdmin +// check if userID is admin on bot +func IsUserAdmin(userID int64) bool { + userL, errl := GetAdminFromEnv() + if len(errl) > 0 { + fmt.Printf("error no admin in var %v", errl) + } + return slices.Contains(userL, userID) +} + +func HasUserAccess(bots []string, botName string) bool { + return slices.Contains(bots, botName) +} + +func GenerateAccessRequestMessage(up models.User, adm int64, b *bot.Bot, update *models.Update) *bot.SendMessageParams { + txt := fmt.Sprintf(`User %s is requesting access +ID: %d +FirstName: %s +LastName: %s +ChatID: %d +`, up.Username, up.ID, up.FirstName, up.LastName, up.ID) + msg := &bot.SendMessageParams{ + ChatID: adm, + Text: txt, + } + + bn, _ := b.GetMe(context.Background()) + kbd := &models.InlineKeyboardMarkup{ + InlineKeyboard: [][]models.InlineKeyboardButton{ + { + {Text: "Grant", CallbackData: fmt.Sprintf("operation=grant&userID=%d&bot=%s", up.ID, bn.Username)}, + {Text: "Deny", CallbackData: fmt.Sprintf("operation=deny&userID=%d&bot=%s", up.ID, bn.Username)}, + }, + { + {Text: "Ignore", CallbackData: fmt.Sprintf("operation=ignore&userID=%d&bot=%s", up.ID, bn.Username)}, + }, + }, + } + msg.ReplyMarkup = kbd + return msg +} diff --git a/internal/application/helpers/file.go b/internal/application/helpers/file.go new file mode 100644 index 0000000..7209683 --- /dev/null +++ b/internal/application/helpers/file.go @@ -0,0 +1,24 @@ +package helpers + +import ( + "bytes" + "context" + "errors" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + + +func SendDocument(ctx context.Context, b *bot.Bot, update *models.Update, document []byte, title string)error{ + if document == nil { + return errors.New("file provided cant be nil") + } + params := &bot.SendDocumentParams{ + ChatID: update.Message.Chat.ID, + Document: &models.InputFileUpload{Filename: title, Data: bytes.NewReader(document)}, + Caption: title, + } + b.SendDocument(ctx, params) + return nil +} diff --git a/internal/application/helpers/keyboard.go b/internal/application/helpers/keyboard.go new file mode 100644 index 0000000..1bcfd3e --- /dev/null +++ b/internal/application/helpers/keyboard.go @@ -0,0 +1,139 @@ +package helpers + +import ( + "errors" + "fmt" + + "github.com/go-telegram/bot/models" +) + +var ( + ErrRowLen = errors.New("row len alredy full") +) + +type InlineKeyboard struct { + Rows []Row +} + +type Row struct { + Len int + Buttons []Button +} + +type Button struct { + Data string + Text string +} + +// AddButton +// Add a button to the last row of the keyboard +// if the row if full and there are still pending buttons +// to add, a new row will be created and buttons added to it +func AddButton(ik *InlineKeyboard, text, data string) { + r := &ik.Rows[len(ik.Rows)-1] + if len(r.Buttons) < r.Len { + button := Button{Text: text, Data: data} + r.Buttons = append(r.Buttons, button) + fmt.Println("ADD BUTTON ", r, ik) + }else { + r = &Row{ + Len: r.Len, + Buttons: []Button{}, + } + button := Button{Text: text, Data: data} + r.Buttons = append(r.Buttons, button) + fmt.Println("ADD BUTTON ", r, ik) + + ik.Rows = append(ik.Rows, *r) + + } +} + +// AddRow +// Create a new row with a len property to limit wide of kbd +func AddRow(ik *InlineKeyboard, len int){ + row := &Row{Len: len, Buttons: []Button{}} + ik.Rows = append(ik.Rows, *row) + fmt.Println("ADD Row ", row.Len) +} + +// CreateKeyBoard +// render the structure into a models.InlineKeyboardMarkup +func (ik *InlineKeyboard) CreateKeyBoard()models.InlineKeyboardMarkup{ + kbd := models.InlineKeyboardMarkup{} + fmt.Println("creating keyboard ---- ", fmt.Sprintf("%#v", ik)) + fmt.Println("row 0 ", ik.Rows[0]) + for _, row := range ik.Rows { + r := []models.InlineKeyboardButton{} + for _, button := range row.Buttons{ + r = append(r, models.InlineKeyboardButton{Text: button.Text, CallbackData: button.Data}) + } + kbd.InlineKeyboard = append(kbd.InlineKeyboard, r) + } + return kbd +} + +// KeyboardWithAcceptCancel +func KeyboardWithAcceptCancel(textData [][]string, buttonSize int, up bool)models.InlineKeyboardMarkup{ + kbd := &InlineKeyboard{} + if up { + AddRow(kbd, 2) + AddButton(kbd, "Accept ✅", "operation=accept") + AddButton(kbd, "Cancel ❌", "operation=cancel") + } + AddRow(kbd, buttonSize) + for _, it := range textData{ + if len(it) ==2{ + AddButton(kbd, it[0], it[1]) + } + } + if !up { + AddRow(kbd, 2) + AddButton(kbd, "Accept ✅", "operation=accept") + AddButton(kbd, "Cancel ❌", "operation=cancel") + } + return kbd.CreateKeyBoard() +} + + +// KeyboardWithCancel +func KeyboardWithCancel(textData [][]string, buttonSize int, up bool)models.InlineKeyboardMarkup{ + kbd := &InlineKeyboard{} + if up { + AddRow(kbd, 2) + AddButton(kbd, "Cancel ❌", "operation=cancel") + } + AddRow(kbd, buttonSize) + for _, it := range textData{ + if len(it) ==2{ + AddButton(kbd, it[0], it[1]) + } + } + if !up { + AddRow(kbd, 2) + AddButton(kbd, "Cancel ❌", "operation=cancel") + } + return kbd.CreateKeyBoard() +} + +// KeyboardWithBackNext +func KeyboardWithBackNext(textData [][]string, buttonSize int, up bool)models.InlineKeyboardMarkup{ + kbd := &InlineKeyboard{} + if up { + AddRow(kbd, 2) + AddButton(kbd, "⬅️ Back", "operation=accept") + AddButton(kbd, "Next ➡️", "operation=cancel") + } + AddRow(kbd, buttonSize) + for _, it := range textData{ + if len(it) ==2{ + AddButton(kbd, it[0], it[1]) + } + } + if !up { + AddRow(kbd, 2) + AddButton(kbd, "⬅️ Back", "operation=accept") + AddButton(kbd, "Next ➡️", "operation=cancel") + } + return kbd.CreateKeyBoard() +} diff --git a/internal/application/helpers/photo.go b/internal/application/helpers/photo.go new file mode 100644 index 0000000..08b6af8 --- /dev/null +++ b/internal/application/helpers/photo.go @@ -0,0 +1,27 @@ +package helpers + +import ( + "bytes" + "context" + "errors" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +func SendPhotos(ctx context.Context, b *bot.Bot, update *models.Update, data []byte, title string)error{ + + if data == nil { + return errors.New("data cant be nil") + } + + params := &bot.SendPhotoParams{ + ChatID: update.Message.Chat.ID, + Photo: &models.InputFileUpload{ + Data: bytes.NewReader(data), + }, + Caption: title, + } + _, err := b.SendPhoto(ctx, params) + return err +} diff --git a/internal/application/messages/messages.go b/internal/application/messages/messages.go new file mode 100644 index 0000000..cf676ed --- /dev/null +++ b/internal/application/messages/messages.go @@ -0,0 +1,32 @@ +package messages + +import ( + "context" + "fmt" + "log/slog" + + "git.maximotejeda.com/maximo/telegram-base-bot/internal/application/helpers" + "git.maximotejeda.com/maximo/telegram-base-bot/internal/application/middlewares" + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +func RegisterMessageHandler(ctx context.Context, log *slog.Logger, b *bot.Bot){ + messageRL := middlewares.CreateRateLimitUser(ctx, log, 15, 1) + // b.RegisterHandler(bot.HandlerTypeMessageText, "hello", bot.MatchTypeExact, HandleHelloMessage, messageRL) + b.RegisterHandler(bot.HandlerTypeMessageText, "h", bot.MatchTypeContains, HandleHelloMessage, messageRL) +} + +func HandleHelloMessage(ctx context.Context, b *bot.Bot, update *models.Update){ +// kbd := &helpers.InlineKeyboard{} + it := [][]string{} + for x := range 9{ + it = append(it, []string{fmt.Sprintf("%d", x), fmt.Sprintf("button_%d", x)}) + } + kb:= helpers.KeyboardWithCancel(it, 3, false) + b.SendMessage(ctx, &bot.SendMessageParams{ + ChatID: update.Message.Chat.ID, + Text: "managing text", + ReplyMarkup: kb, + }) +} diff --git a/internal/application/messages/reactions.go b/internal/application/messages/reactions.go new file mode 100644 index 0000000..4fe1ef3 --- /dev/null +++ b/internal/application/messages/reactions.go @@ -0,0 +1,61 @@ +package messages + +import ( + "context" + "log/slog" + + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) +var ( +log *slog.Logger +) + +// RegisterMessageReactionHandler +// Register a match function for an specific emoji to control reactions from users on messages +// quick reaction emojis: +// "❤", "🔥", "👍", "👎", "🥰", "👏", "😁" +// those are default emojis for telegram client +func RegisterMessageReactionHandler(ctx context.Context, llog *slog.Logger, b *bot.Bot){ + log = llog + heartHandler := matchReaction("❤") + fireHandler := matchReaction("🔥") + thumbsUpHandler := matchReaction("👍") + thumbsDownHandler := matchReaction("👎") + loveHandler := matchReaction("🥰") + clapHandler := matchReaction("👏") + laughtHandler := matchReaction("😁") + b.RegisterHandlerMatchFunc( fireHandler, handler) + b.RegisterHandlerMatchFunc( heartHandler, handler) + b.RegisterHandlerMatchFunc( thumbsUpHandler, handler) + b.RegisterHandlerMatchFunc(thumbsDownHandler, handler) + b.RegisterHandlerMatchFunc(loveHandler, handler) + b.RegisterHandlerMatchFunc(laughtHandler, handler) + b.RegisterHandlerMatchFunc(clapHandler, handler) + +} + +func matchReaction(emoji string)func(*models.Update)bool{ + return func (update *models.Update)bool{ + if update.MessageReaction != nil && len(update.MessageReaction.NewReaction) > 0{ + switch reaction := update.MessageReaction.NewReaction[0].Type; reaction { + case models.ReactionTypeTypeEmoji: + log.Info("emoji message reaction encounter", "reactions", update.MessageReaction.NewReaction) + if update.MessageReaction.NewReaction[0].ReactionTypeEmoji.Emoji == emoji{ + return true + } + } + }else{ + log.Info("not the same character") + return false + } + log.Info("not a reaction") + return false + } + +} + +func handler(ctx context.Context, b *bot.Bot, update *models.Update){ +log.Info("message from reaction") + +} diff --git a/internal/application/middlewares/middlewares.go b/internal/application/middlewares/middlewares.go new file mode 100644 index 0000000..6dd4a6f --- /dev/null +++ b/internal/application/middlewares/middlewares.go @@ -0,0 +1,252 @@ +package middlewares + +import ( + "context" + "fmt" + "log/slog" + "os" + "sync" + "time" + + "git.maximotejeda.com/maximo/telegram-base-bot/config" + "git.maximotejeda.com/maximo/telegram-base-bot/internal/application/helpers" + "git.maximotejeda.com/maximo/telegram-base-bot/internal/ports" + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +var log = slog.New(slog.NewJSONHandler(os.Stderr, nil)) + +func ShowMessageWithUserID(next bot.HandlerFunc) bot.HandlerFunc { + return func(ctx context.Context, bot *bot.Bot, update *models.Update) { + if update.Message != nil { + log.Info("User new message", fmt.Sprintf("%d", update.Message.From.ID), update.Message.Text) + } + next(ctx, bot, update) + } +} + +// singleFlight is a middleware that ensures that only one callback query is processed at a time. +// example from https://github.com/go-telegram/bot/blob/main/examples/middleware/main.go +func SingleFlight(next bot.HandlerFunc) bot.HandlerFunc { + sf := sync.Map{} + return func(ctx context.Context, b *bot.Bot, update *models.Update) { + if update.CallbackQuery != nil { + key := update.CallbackQuery.Message.Message.ID + if _, loaded := sf.LoadOrStore(key, struct{}{}); loaded { + b.SendMessage(ctx, &bot.SendMessageParams{ChatID: update.CallbackQuery.From.ID, Text: "Query on flight, please wait"}) + return + } + defer sf.Delete(key) + next(ctx, b, update) + } + } +} + +func LogMessage(next bot.HandlerFunc) bot.HandlerFunc { + return func(ctx context.Context, b *bot.Bot, update *models.Update) { + txt := "" + user := &models.User{} + if update.CallbackQuery == nil { + txt = update.Message.Text + user = update.Message.From + } else { + txt = fmt.Sprintf("%#v", update.CallbackQuery) + user = &update.CallbackQuery.From + } + log.Info(txt, "user", user.Username) + next(ctx, b, update) + } +} + +func NotifyAdmin(next bot.HandlerFunc) bot.HandlerFunc { + return func(ctx context.Context, b *bot.Bot, update *models.Update) { + next(ctx, b, update) + } +} + +// SetAuthRequired +// set authorization middleware +// authorization will be set to an amount of time in minutes +func SetAuthRequired(svc ports.UserService, log *slog.Logger) func(bot.HandlerFunc) bot.HandlerFunc { + userLastAuth := sync.Map{} + return func(next bot.HandlerFunc) bot.HandlerFunc { + return func(ctx context.Context, b *bot.Bot, update *models.Update) { + var key int64 + if update.CallbackQuery == nil { + if update.MessageReaction == nil { + key = update.Message.From.ID + } + } else { + key = update.CallbackQuery.From.ID + } + slog.Debug("executing auth func","user", key) + authMe := func() { + k := helpers.Authenticate(ctx, log, b, update, svc) + if !k { + log.Info("user not Authenticated", "user", key) + return + } + log.Debug("storing user last auth to map") + userLastAuth.Store(key, time.Now()) + log.Info("user Authenticated", "user", key, "time", time.Now()) + next(ctx, b, update) + } + + if _, loaded := userLastAuth.LoadOrStore(key, time.Now()); loaded { + when, _ := userLastAuth.Load(key) + switch { + case time.Since(when.(time.Time)).Minutes() < 3: // the time user will remain auth on the bot + log.Info("user on cache available", "user", key, "time", when) + next(ctx, b, update) + return + default: + log.Debug("user last auth is more than x min, auth again") + authMe() + + } + } else { + log.Debug("user not auth racently, authenticating") + authMe() + + } + + } + } +} + +func RateLimitUser(next bot.HandlerFunc) bot.HandlerFunc { + rl := sync.Map{} + type data struct { + when time.Time + amount int64 + } + return func(ctx context.Context, b *bot.Bot, update *models.Update) { + var ( + key int64 + rLimitTime = config.GetRateLimitSec() + rLimitAmnt = config.GetRateLimitAmount() + ) + + if update.CallbackQuery == nil { + key = update.Message.From.ID + } else { + key = update.CallbackQuery.From.ID + } + log.Info("got key ", "key ", key) + if _, loaded := rl.LoadOrStore(key, data{when: time.Now(), amount: 0}); loaded { + log.Info("user loaded on map") + dt, _ := rl.Load(key) + dtl := dt.(data) + + dtl.amount++ + amnt := dtl.amount + rl.Store(key, data{when: dtl.when, amount: amnt}) + switch time.Since(dtl.when).Seconds() < rLimitTime { + case true: + switch dtl.amount > rLimitAmnt { + case true: + log.Info("user rl execeed", "since", time.Since(dtl.when).Seconds(), "amount", dtl.amount) + rl.Store(key, data{when: time.Now(), amount: amnt}) + b.SendMessage(ctx, &bot.SendMessageParams{ChatID: key, Text: "Rate Limit Exeded"}) + return + case false: + + log.Info("user rl not execeed", "since", time.Since(dtl.when).Seconds(), "amount", dtl.amount) + } + case false: + rl.Store(key, data{when: time.Now(), amount: 1}) + log.Info("user time", "since", time.Since(dtl.when).Seconds(), "amount", dtl.amount) + } + + } else { + rl.Store(key, data{when: time.Now(), amount: 1}) + log.Info("user not loaded", "user", key) + } + next(ctx, b, update) + } +} + +// CreateRateLimitUser +// Create an specific rate limiting with distincts values of time and hits +func CreateRateLimitUser(ctx context.Context, log *slog.Logger, delay float64, hits int64) func(bot.HandlerFunc) bot.HandlerFunc { + return func(next bot.HandlerFunc) bot.HandlerFunc { + rl := sync.Map{} + type data struct { + when time.Time + amount int64 + } + return func(ctx context.Context, b *bot.Bot, update *models.Update) { + var ( + key int64 + rLimitTime = delay // time in secs to check + rLimitAmnt = hits // number of hits + ) + + if update.CallbackQuery == nil { + key = update.Message.From.ID + } else { + key = update.CallbackQuery.From.ID + } + log.Info("got key ", "key ", key) + if _, loaded := rl.LoadOrStore(key, data{when: time.Now(), amount: 0}); loaded { + log.Info("user loaded on map") + dt, _ := rl.Load(key) + dtl := dt.(data) + + dtl.amount++ + amnt := dtl.amount + rl.Store(key, data{when: dtl.when, amount: amnt}) + switch time.Since(dtl.when).Seconds() < rLimitTime { + case true: + switch dtl.amount >= rLimitAmnt { + case true: + log.Info("user rl execeed", "since", time.Since(dtl.when).Seconds(), "amount", dtl.amount) + rl.Store(key, data{when: time.Now(), amount: amnt}) + b.SendMessage(ctx, &bot.SendMessageParams{ChatID: key, Text: "Rate Limit Exeded"}) + return + case false: + + log.Info("user rl not execeed", "since", time.Since(dtl.when).Seconds(), "amount", dtl.amount) + } + case false: + rl.Store(key, data{when: time.Now(), amount: 1}) + log.Info("user time", "since", time.Since(dtl.when).Seconds(), "amount", dtl.amount) + } + + } else { + log.Info("user not loaded", "user", key) + } + next(ctx, b, update) + } + } +} + +func CreateLogMiddleWare(ctx context.Context, log *slog.Logger) func(bot.HandlerFunc) bot.HandlerFunc{ + return func(next bot.HandlerFunc)bot.HandlerFunc{ + return func(ctx context.Context, b *bot.Bot, update *models.Update) { + start := time.Now() + txt := "" + user := &models.User{} + if update.CallbackQuery == nil { + if update.MessageReaction == nil { + txt = update.Message.Text + user = update.Message.From + }else { + user = update.MessageReaction.User + txt = "reaction" + log.Info("reaction", "react", update.MessageReaction) + } + + + } else { + txt = fmt.Sprintf("%#v", update.CallbackQuery) + user = &update.CallbackQuery.From + } + + log.Info(txt, "user", user.Username) + log.Debug("reponse", "user", user.Username, "id", user.ID, "elapsed ms", time.Since(start).Milliseconds()) + next(ctx, b, update) + } + } +} diff --git a/internal/application/queries/queries.go b/internal/application/queries/queries.go new file mode 100644 index 0000000..6f042e4 --- /dev/null +++ b/internal/application/queries/queries.go @@ -0,0 +1,43 @@ +package queries + +import ( + "context" + "log/slog" + "time" + + "git.maximotejeda.com/maximo/telegram-base-bot/internal/application/middlewares" + "github.com/go-telegram/bot" + "github.com/go-telegram/bot/models" +) + +// RegisterQueries +func RegisterQueries(ctx context.Context, log *slog.Logger, b *bot.Bot){ + b.RegisterHandler(bot.HandlerTypeCallbackQueryData, "button",bot.MatchTypeContains, HandleQuery, middlewares.LogMessage, middlewares.SingleFlight) + b.RegisterHandler(bot.HandlerTypeCallbackQueryData, "operation=cancel",bot.MatchTypeContains, deleteQuery, middlewares.LogMessage, middlewares.SingleFlight) +} + +// HandleQuery +// Example of handle query +func HandleQuery(ctx context.Context, b *bot.Bot, update *models.Update){ + // Simulate work + time.Sleep(2*time.Second) + // send a response + b.SendMessage(ctx, &bot.SendMessageParams{ + ChatID: update.CallbackQuery.From.ID, // seems that query can come only fron one source USER + Text: "handling query " + update.CallbackQuery.Data, + }) +} + +// deleteQuery +// Delete message sending the query +func deleteQuery(ctx context.Context, b *bot.Bot, update *models.Update){ + //Message to delete + mtd := update.CallbackQuery.Message.Message.ID + // Chat where message come from + cwmcf := update.CallbackQuery.From.ID + + b.DeleteMessage(ctx, &bot.DeleteMessageParams{ + ChatID: cwmcf, + MessageID: mtd, + }) +} diff --git a/internal/ports/ports.go b/internal/ports/ports.go new file mode 100644 index 0000000..808de88 --- /dev/null +++ b/internal/ports/ports.go @@ -0,0 +1 @@ +package ports diff --git a/internal/ports/user.go b/internal/ports/user.go new file mode 100644 index 0000000..a15b2bb --- /dev/null +++ b/internal/ports/user.go @@ -0,0 +1,27 @@ +package ports + +import ( + "git.maximotejeda.com/maximo/telegram-base-bot/internal/application/domains" + "github.com/go-telegram/bot/models" + + "git.maximotejeda.com/maximo/tgb-user/proto/golang/tgbuser" +) + +type UserService interface { + Get(int64) (*domains.User, error) + Edit(*models.User) (bool, error) + Delete(int64) (bool, error) + Create(*models.User) (bool, error) + AddBot(int64, string) (bool, error) + GetBots(int64) ([]string, error) + DeleteBot(int64, string) (bool, error) + GetAllBotsUsers(string) ([]*domains.User, error) + CreateBot(string)(error) + CreateAccessRequest(int64, string)(bool, error) + GrantAccess(int64, string)(bool, error) + GetAllAccessRequest(string)(*tgbuser.GetAccessResponse, error) + BanUser(int64, int64, string)(bool, error) + UnBanUser(int64, string)(bool, error) + GetAllBannedUsers(string)(*tgbuser.GetBanResponse, error) + GetAccessRequest(int64) (*tgbuser.GetAccessResponse, error) +} diff --git a/webapp/#index.html# b/webapp/#index.html# new file mode 100644 index 0000000..e97e3d1 --- /dev/null +++ b/webapp/#index.html# @@ -0,0 +1,20 @@ + + + + + + test-mini-app + + + + + +
+ + + + Contenidob0... + +
+ + diff --git a/webapp/index.html b/webapp/index.html new file mode 100644 index 0000000..0128a22 --- /dev/null +++ b/webapp/index.html @@ -0,0 +1,20 @@ + + + + + + test-mini-app + + + + + +
+ + + + Contenido... + +
+ + diff --git a/webapp/static/css/#index.css# b/webapp/static/css/#index.css# new file mode 100644 index 0000000..e69de29 diff --git a/webapp/static/css/index.css b/webapp/static/css/index.css new file mode 100644 index 0000000..e69de29 diff --git a/webapp/static/html/index.html b/webapp/static/html/index.html new file mode 100644 index 0000000..077d751 --- /dev/null +++ b/webapp/static/html/index.html @@ -0,0 +1,20 @@ + + + + + + test-mini-app + + + + + +
+ + + + Content... + +
+ + diff --git a/webapp/static/js/index.js b/webapp/static/js/index.js new file mode 100644 index 0000000..e69de29 diff --git a/webapp/template/index.html.template b/webapp/template/index.html.template new file mode 100644 index 0000000..e69de29