diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..803350a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,43 @@ +# Binaries +*.exe +*.exe~ +*.dll +*.so +*.dylib +bot + +# Test files +*_test.go +*.test + +# Build artifacts +*.out + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Git +.git/ +.gitignore + +# Docker +docker-compose.yml +docker-compose.dev.yml +Dockerfile +.dockerignore + +# Documentation +*.md +LICENSE + +# Environment +.env +.env.* diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2dcb141 --- /dev/null +++ b/.env.example @@ -0,0 +1,18 @@ +# Telegram Configuration +TELEGRAM_BOT_TOKEN=your_bot_token_here +TELEGRAM_BOT_USERNAME=your_bot_username + +# MongoDB Configuration +MONGO_URI=mongodb://localhost:27017 +MONGO_DATABASE=store_scraper_bot +MONGO_TIMEOUT_SECONDS=10 + +# Application Configuration +ENV=DEVELOPMENT +ADMIN_IDS=123456789,987654321 +SOURCE_COMMIT=unknown + +# Optional: Override default constants +APP_CACHE_SECONDS=600 +NUM_DAYS_WARNING_NOT_UPDATED=30 +SCHEDULE_CHECK_APP_TIME=0 7 * * * diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9638606 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# Build stage +FROM golang:1.21-alpine AS builder + +WORKDIR /app + +# Install build dependencies +RUN apk add --no-cache git + +# Copy go mod files +COPY go.mod go.sum ./ +RUN go mod download + +# Copy source code +COPY . . + +# Build the application +RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o bot ./cmd/bot + +# Final stage +FROM alpine:latest + +RUN apk --no-cache add ca-certificates tzdata + +WORKDIR /root/ + +# Copy the binary from builder +COPY --from=builder /app/bot . + +# Expose no ports (bot uses long polling) + +CMD ["./bot"] diff --git a/cmd/bot/main.go b/cmd/bot/main.go new file mode 100644 index 0000000..d5d79a6 --- /dev/null +++ b/cmd/bot/main.go @@ -0,0 +1,71 @@ +package main + +import ( + "log" + "os" + "os/signal" + "syscall" + + "github.com/miti99/store-scraper-bot-go/internal/api/apple" + "github.com/miti99/store-scraper-bot-go/internal/api/google" + "github.com/miti99/store-scraper-bot-go/internal/bot" + "github.com/miti99/store-scraper-bot-go/internal/config" + "github.com/miti99/store-scraper-bot-go/internal/repository" + "github.com/miti99/store-scraper-bot-go/internal/scheduler" + "go.uber.org/zap" +) + +func main() { + // Load configuration + cfg, err := config.Load() + if err != nil { + log.Fatalf("Failed to load configuration: %v", err) + } + defer cfg.Logger.Sync() + + cfg.Logger.Info("Starting Store Scraper Bot", + zap.String("env", string(cfg.Env)), + zap.String("commit", cfg.SourceCommit)) + + // Initialize MongoDB + if err := repository.InitMongoDB(cfg); err != nil { + cfg.Logger.Fatal("Failed to initialize MongoDB", zap.Error(err)) + } + defer repository.Close() + + // Initialize repositories + adminRepo := repository.NewAdminRepository() + groupRepo := repository.NewGroupRepository() + appleAppRepo := repository.NewAppleAppRepository() + googleAppRepo := repository.NewGoogleAppRepository() + + // Initialize scrapers + appleScraper := apple.NewAppleScraper(appleAppRepo, cfg) + googleScraper := google.NewGoogleScraper(googleAppRepo, cfg) + + // Initialize bot + telegramBot, err := bot.NewBot(cfg, adminRepo, groupRepo, appleScraper, googleScraper) + if err != nil { + cfg.Logger.Fatal("Failed to initialize bot", zap.Error(err)) + } + + // Initialize and start scheduler + sched := scheduler.NewScheduler(cfg, telegramBot, adminRepo, groupRepo, appleScraper, googleScraper) + if err := sched.Start(); err != nil { + cfg.Logger.Fatal("Failed to start scheduler", zap.Error(err)) + } + defer sched.Stop() + + // Start bot in a goroutine + go func() { + cfg.Logger.Info("Starting Telegram bot polling") + telegramBot.Start() + }() + + // Wait for interrupt signal + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM) + + <-sigChan + cfg.Logger.Info("Received shutdown signal, stopping bot...") +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..1dae233 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,56 @@ +version: '3.8' + +services: + bot: + build: . + container_name: store-scraper-bot-go-dev + restart: unless-stopped + environment: + - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN} + - TELEGRAM_BOT_USERNAME=${TELEGRAM_BOT_USERNAME} + - MONGO_URI=mongodb://mongodb:27017 + - MONGO_DATABASE=store_scraper_bot_dev + - ENV=DEVELOPMENT + - ADMIN_IDS=${ADMIN_IDS} + - SOURCE_COMMIT=${SOURCE_COMMIT:-dev} + depends_on: + - mongodb + networks: + - bot-network + volumes: + - .:/app + + mongodb: + image: mongo:7.0 + container_name: store-scraper-mongodb-dev + restart: unless-stopped + environment: + - MONGO_INITDB_DATABASE=store_scraper_bot_dev + volumes: + - mongodb_data_dev:/data/db + networks: + - bot-network + ports: + - "27017:27017" + + mongo-express: + image: mongo-express:latest + container_name: mongo-express-dev + restart: unless-stopped + environment: + - ME_CONFIG_MONGODB_URL=mongodb://mongodb:27017 + - ME_CONFIG_BASICAUTH_USERNAME=admin + - ME_CONFIG_BASICAUTH_PASSWORD=admin + depends_on: + - mongodb + networks: + - bot-network + ports: + - "8081:8081" + +networks: + bot-network: + driver: bridge + +volumes: + mongodb_data_dev: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1c9bb1f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ +version: '3.8' + +services: + bot: + build: . + container_name: store-scraper-bot-go + restart: unless-stopped + environment: + - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN} + - TELEGRAM_BOT_USERNAME=${TELEGRAM_BOT_USERNAME} + - MONGO_URI=mongodb://mongodb:27017 + - MONGO_DATABASE=store_scraper_bot + - ENV=${ENV:-PRODUCTION} + - ADMIN_IDS=${ADMIN_IDS} + - SOURCE_COMMIT=${SOURCE_COMMIT:-unknown} + depends_on: + - mongodb + networks: + - bot-network + + mongodb: + image: mongo:7.0 + container_name: store-scraper-mongodb + restart: unless-stopped + environment: + - MONGO_INITDB_DATABASE=store_scraper_bot + volumes: + - mongodb_data:/data/db + networks: + - bot-network + ports: + - "27017:27017" + +networks: + bot-network: + driver: bridge + +volumes: + mongodb_data: diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ee0b8ad --- /dev/null +++ b/go.mod @@ -0,0 +1,24 @@ +module github.com/miti99/store-scraper-bot-go + +go 1.23.4 + +require ( + github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 + github.com/robfig/cron/v3 v3.0.1 + go.mongodb.org/mongo-driver v1.17.6 + go.uber.org/zap v1.27.0 +) + +require ( + github.com/golang/snappy v0.0.4 // indirect + github.com/klauspost/compress v1.16.7 // indirect + github.com/montanaflynn/stats v0.7.1 // indirect + github.com/xdg-go/pbkdf2 v1.0.0 // indirect + github.com/xdg-go/scram v1.1.2 // indirect + github.com/xdg-go/stringprep v1.0.4 // indirect + github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect + go.uber.org/multierr v1.10.0 // indirect + golang.org/x/crypto v0.26.0 // indirect + golang.org/x/sync v0.8.0 // indirect + golang.org/x/text v0.17.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..152df7e --- /dev/null +++ b/go.sum @@ -0,0 +1,66 @@ +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/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= +github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +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.16.7 h1:2mk3MPGNzKyxErAw8YaohYh69+pa4sIQSC0fPGCFR9I= +github.com/klauspost/compress v1.16.7/go.mod h1:ntbaceVETuRiXiv4DpjP66DpAtAGkEQskQzEyD//IeE= +github.com/montanaflynn/stats v0.7.1 h1:etflOAAHORrCC44V+aR6Ftzort912ZU+YLiSTuV8eaE= +github.com/montanaflynn/stats v0.7.1/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +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/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +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.1.2 h1:FHX5I5B4i4hKRVRBCFRxq1iQRej7WO3hhBuJf+UUySY= +github.com/xdg-go/scram v1.1.2/go.mod h1:RT/sEzTbU5y00aCK8UOx6R7YryM0iF1N2MOmC3kKLN4= +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 v1.17.6 h1:87JUG1wZfWsr6rIz3ZmpH90rL5tea7O3IHuSwHUpsss= +go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= +go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= +go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +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.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= +golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= +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.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= +golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +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/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.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= +golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= +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/internal/api/apple/apple_scraper.go b/internal/api/apple/apple_scraper.go new file mode 100644 index 0000000..8308d7c --- /dev/null +++ b/internal/api/apple/apple_scraper.go @@ -0,0 +1,115 @@ +package apple + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/miti99/store-scraper-bot-go/internal/config" + "github.com/miti99/store-scraper-bot-go/internal/model" + "github.com/miti99/store-scraper-bot-go/internal/repository" + "go.uber.org/zap" +) + +const appleAPIURL = "https://store-scraper.vercel.app/apple/app" + +type AppleAppRequest struct { + ID *int64 `json:"id,omitempty"` + AppID *string `json:"appId,omitempty"` + Country string `json:"country"` + Ratings bool `json:"ratings"` +} + +type AppleScraper struct { + httpClient *http.Client + appRepo *repository.AppleAppRepository + logger *zap.Logger +} + +func NewAppleScraper(appRepo *repository.AppleAppRepository, cfg *config.Config) *AppleScraper { + return &AppleScraper{ + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + appRepo: appRepo, + logger: cfg.Logger, + } +} + +func (s *AppleScraper) GetApp(appID, country string) (*model.AppleAppResponse, error) { + // Check cache first + cachedApp, err := s.appRepo.GetCached(appID) + if err != nil { + s.logger.Error("Failed to get cached apple app", zap.Error(err), zap.String("appId", appID)) + } + if cachedApp != nil { + s.logger.Debug("Returning cached apple app", zap.String("appId", appID)) + return &cachedApp.App, nil + } + + // Fetch from API + s.logger.Info("Fetching apple app from API", zap.String("appId", appID), zap.String("country", country)) + response, err := s.fetchFromAPI(appID, country) + if err != nil { + return nil, err + } + + // Save to cache + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + appleApp := model.NewAppleApp(appID, *response) + if err := s.appRepo.Save(ctx, appleApp); err != nil { + s.logger.Error("Failed to save apple app to cache", zap.Error(err), zap.String("appId", appID)) + } + + return response, nil +} + +func (s *AppleScraper) fetchFromAPI(appID, country string) (*model.AppleAppResponse, error) { + request := AppleAppRequest{ + AppID: &appID, + Country: country, + Ratings: true, + } + + requestBody, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequest("POST", appleAPIURL, bytes.NewBuffer(requestBody)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API returned status code: %d", resp.StatusCode) + } + + var response model.AppleAppResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &response, nil +} + +func (s *AppleScraper) GetAppUpdated(appID, country string) (string, error) { + app, err := s.GetApp(appID, country) + if err != nil { + return "", err + } + return app.Updated, nil +} diff --git a/internal/api/google/google_scraper.go b/internal/api/google/google_scraper.go new file mode 100644 index 0000000..5692595 --- /dev/null +++ b/internal/api/google/google_scraper.go @@ -0,0 +1,112 @@ +package google + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/miti99/store-scraper-bot-go/internal/config" + "github.com/miti99/store-scraper-bot-go/internal/model" + "github.com/miti99/store-scraper-bot-go/internal/repository" + "go.uber.org/zap" +) + +const googleAPIURL = "https://store-scraper.vercel.app/google/app" + +type GoogleAppRequest struct { + AppID string `json:"appId"` + Country string `json:"country"` +} + +type GoogleScraper struct { + httpClient *http.Client + appRepo *repository.GoogleAppRepository + logger *zap.Logger +} + +func NewGoogleScraper(appRepo *repository.GoogleAppRepository, cfg *config.Config) *GoogleScraper { + return &GoogleScraper{ + httpClient: &http.Client{ + Timeout: 30 * time.Second, + }, + appRepo: appRepo, + logger: cfg.Logger, + } +} + +func (s *GoogleScraper) GetApp(appID, country string) (*model.GoogleAppResponse, error) { + // Check cache first + cachedApp, err := s.appRepo.GetCached(appID) + if err != nil { + s.logger.Error("Failed to get cached google app", zap.Error(err), zap.String("appId", appID)) + } + if cachedApp != nil { + s.logger.Debug("Returning cached google app", zap.String("appId", appID)) + return &cachedApp.App, nil + } + + // Fetch from API + s.logger.Info("Fetching google app from API", zap.String("appId", appID), zap.String("country", country)) + response, err := s.fetchFromAPI(appID, country) + if err != nil { + return nil, err + } + + // Save to cache + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + googleApp := model.NewGoogleApp(appID, *response) + if err := s.appRepo.Save(ctx, googleApp); err != nil { + s.logger.Error("Failed to save google app to cache", zap.Error(err), zap.String("appId", appID)) + } + + return response, nil +} + +func (s *GoogleScraper) fetchFromAPI(appID, country string) (*model.GoogleAppResponse, error) { + request := GoogleAppRequest{ + AppID: appID, + Country: country, + } + + requestBody, err := json.Marshal(request) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequest("POST", googleAPIURL, bytes.NewBuffer(requestBody)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + + resp, err := s.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API returned status code: %d", resp.StatusCode) + } + + var response model.GoogleAppResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("failed to decode response: %w", err) + } + + return &response, nil +} + +func (s *GoogleScraper) GetLastUpdate(appID, country string) (int64, error) { + app, err := s.GetApp(appID, country) + if err != nil { + return 0, err + } + return app.Updated, nil +} diff --git a/internal/bot/bot.go b/internal/bot/bot.go new file mode 100644 index 0000000..c74b531 --- /dev/null +++ b/internal/bot/bot.go @@ -0,0 +1,136 @@ +package bot + +import ( + "fmt" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/miti99/store-scraper-bot-go/internal/api/apple" + "github.com/miti99/store-scraper-bot-go/internal/api/google" + "github.com/miti99/store-scraper-bot-go/internal/bot/command" + "github.com/miti99/store-scraper-bot-go/internal/config" + "github.com/miti99/store-scraper-bot-go/internal/repository" + "go.uber.org/zap" +) + +type Bot struct { + api *tgbotapi.BotAPI + cfg *config.Config + adminRepo *repository.AdminRepository + groupRepo *repository.GroupRepository + appleScraper *apple.AppleScraper + googleScraper *google.GoogleScraper + commands map[string]command.Command + logger *zap.Logger +} + +func NewBot( + cfg *config.Config, + adminRepo *repository.AdminRepository, + groupRepo *repository.GroupRepository, + appleScraper *apple.AppleScraper, + googleScraper *google.GoogleScraper, +) (*Bot, error) { + bot, err := tgbotapi.NewBotAPI(cfg.TelegramBotToken) + if err != nil { + return nil, fmt.Errorf("failed to create telegram bot: %w", err) + } + + bot.Debug = cfg.Env == config.Development + + cfg.Logger.Info("Authorized on account", zap.String("username", bot.Self.UserName)) + + b := &Bot{ + api: bot, + cfg: cfg, + adminRepo: adminRepo, + groupRepo: groupRepo, + appleScraper: appleScraper, + googleScraper: googleScraper, + commands: make(map[string]command.Command), + logger: cfg.Logger, + } + + b.registerCommands() + return b, nil +} + +func (b *Bot) registerCommands() { + b.commands["addgroup"] = command.NewAddGroupCommand(b.cfg, b.adminRepo, b.groupRepo) + b.commands["deletegroup"] = command.NewDeleteGroupCommand(b.cfg, b.adminRepo, b.groupRepo) + b.commands["listgroup"] = command.NewListGroupCommand(b.cfg, b.adminRepo) + b.commands["addapple"] = command.NewAddAppleAppCommand(b.cfg, b.adminRepo, b.groupRepo, b.appleScraper) + b.commands["deleteapple"] = command.NewDeleteAppleAppCommand(b.cfg, b.adminRepo, b.groupRepo) + b.commands["addgoogle"] = command.NewAddGoogleAppCommand(b.cfg, b.adminRepo, b.groupRepo, b.googleScraper) + b.commands["deletegoogle"] = command.NewDeleteGoogleAppCommand(b.cfg, b.adminRepo, b.groupRepo) + b.commands["listapp"] = command.NewListAppCommand(b.cfg, b.adminRepo, b.groupRepo) + b.commands["checkapp"] = command.NewCheckAppCommand(b.cfg, b.adminRepo, b.groupRepo, b.appleScraper, b.googleScraper) + b.commands["checkappscores"] = command.NewCheckAppScoresCommand(b.cfg, b.adminRepo, b.groupRepo, b.appleScraper, b.googleScraper) + b.commands["rawapple"] = command.NewRawAppleAppCommand(b.cfg, b.appleScraper) + b.commands["rawgoogle"] = command.NewRawGoogleAppCommand(b.cfg, b.googleScraper) + b.commands["info"] = command.NewInfoCommand(b.cfg) +} + +func (b *Bot) Start() { + u := tgbotapi.NewUpdate(0) + u.Timeout = 60 + + updates := b.api.GetUpdatesChan(u) + + for update := range updates { + if update.Message == nil { + continue + } + + if !update.Message.IsCommand() { + continue + } + + go b.handleCommand(update.Message) + } +} + +func (b *Bot) handleCommand(message *tgbotapi.Message) { + commandName := message.Command() + cmd, exists := b.commands[commandName] + + if !exists { + b.logger.Debug("Unknown command", zap.String("command", commandName)) + return + } + + b.logger.Info("Executing command", + zap.String("command", commandName), + zap.Int64("userId", message.From.ID), + zap.Int64("chatId", message.Chat.ID)) + + response := cmd.Execute(message) + if response != "" { + msg := tgbotapi.NewMessage(message.Chat.ID, response) + msg.ParseMode = "Markdown" + msg.DisableWebPagePreview = true + + if _, err := b.api.Send(msg); err != nil { + b.logger.Error("Failed to send message", zap.Error(err)) + } + } +} + +func (b *Bot) SendMessage(chatID int64, text string) error { + msg := tgbotapi.NewMessage(chatID, text) + msg.ParseMode = "Markdown" + msg.DisableWebPagePreview = true + msg.DisableNotification = false + + _, err := b.api.Send(msg) + return err +} + +func (b *Bot) SendMessageSilent(chatID int64, text string) error { + msg := tgbotapi.NewMessage(chatID, text) + msg.ParseMode = "Markdown" + msg.DisableWebPagePreview = true + msg.DisableNotification = true + + _, err := b.api.Send(msg) + return err +} diff --git a/internal/bot/command/add_apple_app.go b/internal/bot/command/add_apple_app.go new file mode 100644 index 0000000..2f80900 --- /dev/null +++ b/internal/bot/command/add_apple_app.go @@ -0,0 +1,70 @@ +package command + +import ( + "fmt" + "strings" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/miti99/store-scraper-bot-go/internal/api/apple" + "github.com/miti99/store-scraper-bot-go/internal/config" + "github.com/miti99/store-scraper-bot-go/internal/repository" +) + +type AddAppleAppCommand struct { + BaseCommand + adminRepo *repository.AdminRepository + groupRepo *repository.GroupRepository + appleScraper *apple.AppleScraper +} + +func NewAddAppleAppCommand( + cfg *config.Config, + adminRepo *repository.AdminRepository, + groupRepo *repository.GroupRepository, + appleScraper *apple.AppleScraper, +) *AddAppleAppCommand { + return &AddAppleAppCommand{ + BaseCommand: BaseCommand{cfg: cfg}, + adminRepo: adminRepo, + groupRepo: groupRepo, + appleScraper: appleScraper, + } +} + +func (c *AddAppleAppCommand) Execute(message *tgbotapi.Message) string { + if !c.requireAdmin(message) { + return "You are not authorized to use this command." + } + + groupID := message.Chat.ID + hasGroup, err := c.adminRepo.HasGroup(groupID) + if err != nil { + return fmt.Sprintf("Failed to check group: %v", err) + } + if !hasGroup { + return "This group is not registered. Please use /addgroup first." + } + + args := strings.Fields(message.CommandArguments()) + if len(args) == 0 { + return "Usage: /addapple [country]\nExample: /addapple com.example.app vn" + } + + appID := args[0] + country := "vn" + if len(args) > 1 { + country = args[1] + } + + // Verify app exists + app, err := c.appleScraper.GetApp(appID, country) + if err != nil { + return fmt.Sprintf("Failed to fetch app from store: %v", err) + } + + if err := c.groupRepo.AddAppleApp(groupID, appID, country); err != nil { + return fmt.Sprintf("Failed to add app: %v", err) + } + + return fmt.Sprintf("Apple app added successfully:\n*%s*\nApp ID: %s\nCountry: %s\nScore: %.1f", app.Title, appID, country, app.Score) +} diff --git a/internal/bot/command/add_google_app.go b/internal/bot/command/add_google_app.go new file mode 100644 index 0000000..e88e998 --- /dev/null +++ b/internal/bot/command/add_google_app.go @@ -0,0 +1,70 @@ +package command + +import ( + "fmt" + "strings" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/miti99/store-scraper-bot-go/internal/api/google" + "github.com/miti99/store-scraper-bot-go/internal/config" + "github.com/miti99/store-scraper-bot-go/internal/repository" +) + +type AddGoogleAppCommand struct { + BaseCommand + adminRepo *repository.AdminRepository + groupRepo *repository.GroupRepository + googleScraper *google.GoogleScraper +} + +func NewAddGoogleAppCommand( + cfg *config.Config, + adminRepo *repository.AdminRepository, + groupRepo *repository.GroupRepository, + googleScraper *google.GoogleScraper, +) *AddGoogleAppCommand { + return &AddGoogleAppCommand{ + BaseCommand: BaseCommand{cfg: cfg}, + adminRepo: adminRepo, + groupRepo: groupRepo, + googleScraper: googleScraper, + } +} + +func (c *AddGoogleAppCommand) Execute(message *tgbotapi.Message) string { + if !c.requireAdmin(message) { + return "You are not authorized to use this command." + } + + groupID := message.Chat.ID + hasGroup, err := c.adminRepo.HasGroup(groupID) + if err != nil { + return fmt.Sprintf("Failed to check group: %v", err) + } + if !hasGroup { + return "This group is not registered. Please use /addgroup first." + } + + args := strings.Fields(message.CommandArguments()) + if len(args) == 0 { + return "Usage: /addgoogle [country]\nExample: /addgoogle com.example.app vn" + } + + appID := args[0] + country := "vn" + if len(args) > 1 { + country = args[1] + } + + // Verify app exists + app, err := c.googleScraper.GetApp(appID, country) + if err != nil { + return fmt.Sprintf("Failed to fetch app from store: %v", err) + } + + if err := c.groupRepo.AddGoogleApp(groupID, appID, country); err != nil { + return fmt.Sprintf("Failed to add app: %v", err) + } + + return fmt.Sprintf("Google app added successfully:\n*%s*\nApp ID: %s\nCountry: %s\nScore: %.1f", app.Title, appID, country, app.Score) +} diff --git a/internal/bot/command/add_group.go b/internal/bot/command/add_group.go new file mode 100644 index 0000000..e9b50a5 --- /dev/null +++ b/internal/bot/command/add_group.go @@ -0,0 +1,36 @@ +package command + +import ( + "fmt" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/miti99/store-scraper-bot-go/internal/config" + "github.com/miti99/store-scraper-bot-go/internal/repository" +) + +type AddGroupCommand struct { + BaseCommand + adminRepo *repository.AdminRepository + groupRepo *repository.GroupRepository +} + +func NewAddGroupCommand(cfg *config.Config, adminRepo *repository.AdminRepository, groupRepo *repository.GroupRepository) *AddGroupCommand { + return &AddGroupCommand{ + BaseCommand: BaseCommand{cfg: cfg}, + adminRepo: adminRepo, + groupRepo: groupRepo, + } +} + +func (c *AddGroupCommand) Execute(message *tgbotapi.Message) string { + if !c.requireAdmin(message) { + return "You are not authorized to use this command." + } + + groupID := message.Chat.ID + if err := c.adminRepo.AddGroup(groupID); err != nil { + return fmt.Sprintf("Failed to add group: %v", err) + } + + return fmt.Sprintf("Group %d has been added successfully.", groupID) +} diff --git a/internal/bot/command/check_app.go b/internal/bot/command/check_app.go new file mode 100644 index 0000000..db41d5c --- /dev/null +++ b/internal/bot/command/check_app.go @@ -0,0 +1,165 @@ +package command + +import ( + "context" + "fmt" + "strings" + "time" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/miti99/store-scraper-bot-go/internal/api/apple" + "github.com/miti99/store-scraper-bot-go/internal/api/google" + "github.com/miti99/store-scraper-bot-go/internal/config" + "github.com/miti99/store-scraper-bot-go/internal/model" + "github.com/miti99/store-scraper-bot-go/internal/repository" + "github.com/miti99/store-scraper-bot-go/internal/util" + "go.uber.org/zap" +) + +type CheckAppCommand struct { + BaseCommand + adminRepo *repository.AdminRepository + groupRepo *repository.GroupRepository + appleScraper *apple.AppleScraper + googleScraper *google.GoogleScraper +} + +func NewCheckAppCommand( + cfg *config.Config, + adminRepo *repository.AdminRepository, + groupRepo *repository.GroupRepository, + appleScraper *apple.AppleScraper, + googleScraper *google.GoogleScraper, +) *CheckAppCommand { + return &CheckAppCommand{ + BaseCommand: BaseCommand{cfg: cfg}, + adminRepo: adminRepo, + groupRepo: groupRepo, + appleScraper: appleScraper, + googleScraper: googleScraper, + } +} + +func (c *CheckAppCommand) Execute(message *tgbotapi.Message) string { + if !c.requireAdmin(message) { + return "You are not authorized to use this command." + } + + groupID := message.Chat.ID + hasGroup, err := c.adminRepo.HasGroup(groupID) + if err != nil { + return fmt.Sprintf("Failed to check group: %v", err) + } + if !hasGroup { + return "This group is not registered." + } + + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + + group, err := c.groupRepo.Get(ctx, groupID) + if err != nil { + return fmt.Sprintf("Failed to get group: %v", err) + } + + if len(group.AppleApps) == 0 && len(group.GoogleApps) == 0 { + return "No apps in this group." + } + + nonUpdatedApps := make([]model.NonUpdatedApp, 0) + now := time.Now().In(c.cfg.VietnamLocation) + + // Check Apple apps + for _, appInfo := range group.AppleApps { + app, err := c.appleScraper.GetApp(appInfo.AppID, appInfo.Country) + if err != nil { + c.cfg.Logger.Error("Failed to fetch Apple app", + zap.String("appId", appInfo.AppID), + zap.Error(err)) + continue + } + + updatedTime, err := time.Parse(time.RFC3339, app.Updated) + if err != nil { + c.cfg.Logger.Error("Failed to parse update time", + zap.String("appId", appInfo.AppID), + zap.String("updated", app.Updated), + zap.Error(err)) + continue + } + + daysSinceUpdate := int(now.Sub(updatedTime).Hours() / 24) + if daysSinceUpdate > c.cfg.NumDaysWarningNotUpdated { + nonUpdatedApps = append(nonUpdatedApps, model.NonUpdatedApp{ + AppID: appInfo.AppID, + Title: app.Title, + Days: daysSinceUpdate, + Updated: app.Updated[:10], // Just the date part + Score: app.Score, + Reviews: app.Reviews, + Ratings: app.Ratings, + IsApple: true, + }) + } + } + + // Check Google apps + for _, appInfo := range group.GoogleApps { + app, err := c.googleScraper.GetApp(appInfo.AppID, appInfo.Country) + if err != nil { + c.cfg.Logger.Error("Failed to fetch Google app", + zap.String("appId", appInfo.AppID), + zap.Error(err)) + continue + } + + updatedTime := time.UnixMilli(app.Updated) + daysSinceUpdate := int(now.Sub(updatedTime).Hours() / 24) + + if daysSinceUpdate > c.cfg.NumDaysWarningNotUpdated { + nonUpdatedApps = append(nonUpdatedApps, model.NonUpdatedApp{ + AppID: appInfo.AppID, + Title: app.Title, + Days: daysSinceUpdate, + Updated: updatedTime.Format("2006-01-02"), + Score: app.Score, + Reviews: app.Reviews, + Ratings: app.Ratings, + IsApple: false, + }) + } + } + + if len(nonUpdatedApps) == 0 { + return fmt.Sprintf("All apps are up to date (checked within %d days).", c.cfg.NumDaysWarningNotUpdated) + } + + // Build table + var rows [][]string + for _, app := range nonUpdatedApps { + store := "Google" + if app.IsApple { + store = "Apple" + } + + rows = append(rows, []string{ + util.TruncateString(app.Title, 30), + store, + fmt.Sprintf("%d", app.Days), + app.Updated, + fmt.Sprintf("%.1f", app.Score), + fmt.Sprintf("%v", app.Reviews), + util.FormatNumber(app.Ratings), + }) + } + + headers := []string{"App", "Store", "Days", "Updated", "Score", "Reviews", "Ratings"} + table := util.BuildTable(headers, rows) + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("*Non-Updated Apps Report*\nGroup: %d\nApps not updated in >%d days: *%d*\n\n", + groupID, c.cfg.NumDaysWarningNotUpdated, len(nonUpdatedApps))) + sb.WriteString(table) + + return sb.String() +} diff --git a/internal/bot/command/check_app_scores.go b/internal/bot/command/check_app_scores.go new file mode 100644 index 0000000..7bf1e60 --- /dev/null +++ b/internal/bot/command/check_app_scores.go @@ -0,0 +1,123 @@ +package command + +import ( + "context" + "fmt" + "strings" + "time" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/miti99/store-scraper-bot-go/internal/api/apple" + "github.com/miti99/store-scraper-bot-go/internal/api/google" + "github.com/miti99/store-scraper-bot-go/internal/config" + "github.com/miti99/store-scraper-bot-go/internal/repository" + "github.com/miti99/store-scraper-bot-go/internal/util" +) + +type CheckAppScoresCommand struct { + BaseCommand + adminRepo *repository.AdminRepository + groupRepo *repository.GroupRepository + appleScraper *apple.AppleScraper + googleScraper *google.GoogleScraper +} + +func NewCheckAppScoresCommand( + cfg *config.Config, + adminRepo *repository.AdminRepository, + groupRepo *repository.GroupRepository, + appleScraper *apple.AppleScraper, + googleScraper *google.GoogleScraper, +) *CheckAppScoresCommand { + return &CheckAppScoresCommand{ + BaseCommand: BaseCommand{cfg: cfg}, + adminRepo: adminRepo, + groupRepo: groupRepo, + appleScraper: appleScraper, + googleScraper: googleScraper, + } +} + +func (c *CheckAppScoresCommand) Execute(message *tgbotapi.Message) string { + if !c.requireAdmin(message) { + return "You are not authorized to use this command." + } + + groupID := message.Chat.ID + hasGroup, err := c.adminRepo.HasGroup(groupID) + if err != nil { + return fmt.Sprintf("Failed to check group: %v", err) + } + if !hasGroup { + return "This group is not registered." + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + group, err := c.groupRepo.Get(ctx, groupID) + if err != nil { + return fmt.Sprintf("Failed to get group: %v", err) + } + + if len(group.AppleApps) == 0 && len(group.GoogleApps) == 0 { + return "No apps in this group." + } + + var rows [][]string + + // Check Apple apps + for _, appInfo := range group.AppleApps { + app, err := c.appleScraper.GetApp(appInfo.AppID, appInfo.Country) + if err != nil { + rows = append(rows, []string{ + util.TruncateString(appInfo.AppID, 30), + "Apple", + "Error", + "0", + "0", + }) + continue + } + + rows = append(rows, []string{ + util.TruncateString(app.Title, 30), + "Apple", + fmt.Sprintf("%.1f", app.Score), + fmt.Sprintf("%d", app.Reviews), + util.FormatNumber(app.Ratings), + }) + } + + // Check Google apps + for _, appInfo := range group.GoogleApps { + app, err := c.googleScraper.GetApp(appInfo.AppID, appInfo.Country) + if err != nil { + rows = append(rows, []string{ + util.TruncateString(appInfo.AppID, 30), + "Google", + "Error", + "0", + "0", + }) + continue + } + + rows = append(rows, []string{ + util.TruncateString(app.Title, 30), + "Google", + fmt.Sprintf("%.1f", app.Score), + fmt.Sprintf("%d", app.Reviews), + util.FormatNumber(app.Ratings), + }) + } + + headers := []string{"App", "Store", "Score", "Reviews", "Ratings"} + table := util.BuildTable(headers, rows) + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("*App Scores Report*\nGroup: %d\n\n", groupID)) + sb.WriteString(table) + + return sb.String() +} diff --git a/internal/bot/command/command.go b/internal/bot/command/command.go new file mode 100644 index 0000000..d85d2f0 --- /dev/null +++ b/internal/bot/command/command.go @@ -0,0 +1,25 @@ +package command + +import ( + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/miti99/store-scraper-bot-go/internal/config" +) + +type Command interface { + Execute(message *tgbotapi.Message) string +} + +type BaseCommand struct { + cfg *config.Config +} + +func (c *BaseCommand) isAdmin(userID int64) bool { + return c.cfg.IsAdmin(userID) +} + +func (c *BaseCommand) requireAdmin(message *tgbotapi.Message) bool { + if !c.isAdmin(message.From.ID) { + return false + } + return true +} diff --git a/internal/bot/command/delete_apple_app.go b/internal/bot/command/delete_apple_app.go new file mode 100644 index 0000000..f3d3222 --- /dev/null +++ b/internal/bot/command/delete_apple_app.go @@ -0,0 +1,52 @@ +package command + +import ( + "fmt" + "strings" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/miti99/store-scraper-bot-go/internal/config" + "github.com/miti99/store-scraper-bot-go/internal/repository" +) + +type DeleteAppleAppCommand struct { + BaseCommand + adminRepo *repository.AdminRepository + groupRepo *repository.GroupRepository +} + +func NewDeleteAppleAppCommand(cfg *config.Config, adminRepo *repository.AdminRepository, groupRepo *repository.GroupRepository) *DeleteAppleAppCommand { + return &DeleteAppleAppCommand{ + BaseCommand: BaseCommand{cfg: cfg}, + adminRepo: adminRepo, + groupRepo: groupRepo, + } +} + +func (c *DeleteAppleAppCommand) Execute(message *tgbotapi.Message) string { + if !c.requireAdmin(message) { + return "You are not authorized to use this command." + } + + groupID := message.Chat.ID + hasGroup, err := c.adminRepo.HasGroup(groupID) + if err != nil { + return fmt.Sprintf("Failed to check group: %v", err) + } + if !hasGroup { + return "This group is not registered." + } + + args := strings.Fields(message.CommandArguments()) + if len(args) == 0 { + return "Usage: /deleteapple \nExample: /deleteapple com.example.app" + } + + appID := args[0] + + if err := c.groupRepo.RemoveAppleApp(groupID, appID); err != nil { + return fmt.Sprintf("Failed to remove app: %v", err) + } + + return fmt.Sprintf("Apple app %s has been removed successfully.", appID) +} diff --git a/internal/bot/command/delete_google_app.go b/internal/bot/command/delete_google_app.go new file mode 100644 index 0000000..0f06ffa --- /dev/null +++ b/internal/bot/command/delete_google_app.go @@ -0,0 +1,52 @@ +package command + +import ( + "fmt" + "strings" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/miti99/store-scraper-bot-go/internal/config" + "github.com/miti99/store-scraper-bot-go/internal/repository" +) + +type DeleteGoogleAppCommand struct { + BaseCommand + adminRepo *repository.AdminRepository + groupRepo *repository.GroupRepository +} + +func NewDeleteGoogleAppCommand(cfg *config.Config, adminRepo *repository.AdminRepository, groupRepo *repository.GroupRepository) *DeleteGoogleAppCommand { + return &DeleteGoogleAppCommand{ + BaseCommand: BaseCommand{cfg: cfg}, + adminRepo: adminRepo, + groupRepo: groupRepo, + } +} + +func (c *DeleteGoogleAppCommand) Execute(message *tgbotapi.Message) string { + if !c.requireAdmin(message) { + return "You are not authorized to use this command." + } + + groupID := message.Chat.ID + hasGroup, err := c.adminRepo.HasGroup(groupID) + if err != nil { + return fmt.Sprintf("Failed to check group: %v", err) + } + if !hasGroup { + return "This group is not registered." + } + + args := strings.Fields(message.CommandArguments()) + if len(args) == 0 { + return "Usage: /deletegoogle \nExample: /deletegoogle com.example.app" + } + + appID := args[0] + + if err := c.groupRepo.RemoveGoogleApp(groupID, appID); err != nil { + return fmt.Sprintf("Failed to remove app: %v", err) + } + + return fmt.Sprintf("Google app %s has been removed successfully.", appID) +} diff --git a/internal/bot/command/delete_group.go b/internal/bot/command/delete_group.go new file mode 100644 index 0000000..d849cb6 --- /dev/null +++ b/internal/bot/command/delete_group.go @@ -0,0 +1,46 @@ +package command + +import ( + "context" + "fmt" + "time" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/miti99/store-scraper-bot-go/internal/config" + "github.com/miti99/store-scraper-bot-go/internal/repository" +) + +type DeleteGroupCommand struct { + BaseCommand + adminRepo *repository.AdminRepository + groupRepo *repository.GroupRepository +} + +func NewDeleteGroupCommand(cfg *config.Config, adminRepo *repository.AdminRepository, groupRepo *repository.GroupRepository) *DeleteGroupCommand { + return &DeleteGroupCommand{ + BaseCommand: BaseCommand{cfg: cfg}, + adminRepo: adminRepo, + groupRepo: groupRepo, + } +} + +func (c *DeleteGroupCommand) Execute(message *tgbotapi.Message) string { + if !c.requireAdmin(message) { + return "You are not authorized to use this command." + } + + groupID := message.Chat.ID + + if err := c.adminRepo.RemoveGroup(groupID); err != nil { + return fmt.Sprintf("Failed to remove group: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := c.groupRepo.Delete(ctx, groupID); err != nil { + return fmt.Sprintf("Group removed from admin but failed to delete group data: %v", err) + } + + return fmt.Sprintf("Group %d has been deleted successfully.", groupID) +} diff --git a/internal/bot/command/info.go b/internal/bot/command/info.go new file mode 100644 index 0000000..23ab0e8 --- /dev/null +++ b/internal/bot/command/info.go @@ -0,0 +1,49 @@ +package command + +import ( + "fmt" + "runtime" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/miti99/store-scraper-bot-go/internal/config" +) + +type InfoCommand struct { + BaseCommand +} + +func NewInfoCommand(cfg *config.Config) *InfoCommand { + return &InfoCommand{ + BaseCommand: BaseCommand{cfg: cfg}, + } +} + +func (c *InfoCommand) Execute(message *tgbotapi.Message) string { + return fmt.Sprintf(`*Store Scraper Bot - Go Edition* + +*Version:* 1.0.0 +*Environment:* %s +*Source Commit:* %s +*Go Version:* %s +*Bot Username:* @%s + +*Commands:* +/addgroup - Add current group to monitoring +/deletegroup - Remove current group +/listgroup - List all monitored groups +/addapple [country] - Add Apple app +/deleteapple - Remove Apple app +/addgoogle [country] - Add Google app +/deletegoogle - Remove Google app +/listapp - List apps in current group +/checkapp - Check for non-updated apps +/checkappscores - Check app scores +/rawapple [country] - Get raw Apple data +/rawgoogle [country] - Get raw Google data +/info - Show this info`, + c.cfg.Env, + c.cfg.SourceCommit, + runtime.Version(), + c.cfg.TelegramBotUsername, + ) +} diff --git a/internal/bot/command/list_app.go b/internal/bot/command/list_app.go new file mode 100644 index 0000000..31b93be --- /dev/null +++ b/internal/bot/command/list_app.go @@ -0,0 +1,73 @@ +package command + +import ( + "context" + "fmt" + "strings" + "time" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/miti99/store-scraper-bot-go/internal/config" + "github.com/miti99/store-scraper-bot-go/internal/repository" +) + +type ListAppCommand struct { + BaseCommand + adminRepo *repository.AdminRepository + groupRepo *repository.GroupRepository +} + +func NewListAppCommand(cfg *config.Config, adminRepo *repository.AdminRepository, groupRepo *repository.GroupRepository) *ListAppCommand { + return &ListAppCommand{ + BaseCommand: BaseCommand{cfg: cfg}, + adminRepo: adminRepo, + groupRepo: groupRepo, + } +} + +func (c *ListAppCommand) Execute(message *tgbotapi.Message) string { + if !c.requireAdmin(message) { + return "You are not authorized to use this command." + } + + groupID := message.Chat.ID + hasGroup, err := c.adminRepo.HasGroup(groupID) + if err != nil { + return fmt.Sprintf("Failed to check group: %v", err) + } + if !hasGroup { + return "This group is not registered." + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + group, err := c.groupRepo.Get(ctx, groupID) + if err != nil { + return fmt.Sprintf("Failed to get group: %v", err) + } + + var sb strings.Builder + sb.WriteString("*Apps in this group:*\n\n") + + if len(group.AppleApps) > 0 { + sb.WriteString("*Apple Apps:*\n") + for i, app := range group.AppleApps { + sb.WriteString(fmt.Sprintf("%d. %s (%s)\n", i+1, app.AppID, app.Country)) + } + sb.WriteString("\n") + } + + if len(group.GoogleApps) > 0 { + sb.WriteString("*Google Apps:*\n") + for i, app := range group.GoogleApps { + sb.WriteString(fmt.Sprintf("%d. %s (%s)\n", i+1, app.AppID, app.Country)) + } + } + + if len(group.AppleApps) == 0 && len(group.GoogleApps) == 0 { + return "No apps in this group." + } + + return sb.String() +} diff --git a/internal/bot/command/list_group.go b/internal/bot/command/list_group.go new file mode 100644 index 0000000..58bfdee --- /dev/null +++ b/internal/bot/command/list_group.go @@ -0,0 +1,45 @@ +package command + +import ( + "fmt" + "strings" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/miti99/store-scraper-bot-go/internal/config" + "github.com/miti99/store-scraper-bot-go/internal/repository" +) + +type ListGroupCommand struct { + BaseCommand + adminRepo *repository.AdminRepository +} + +func NewListGroupCommand(cfg *config.Config, adminRepo *repository.AdminRepository) *ListGroupCommand { + return &ListGroupCommand{ + BaseCommand: BaseCommand{cfg: cfg}, + adminRepo: adminRepo, + } +} + +func (c *ListGroupCommand) Execute(message *tgbotapi.Message) string { + if !c.requireAdmin(message) { + return "You are not authorized to use this command." + } + + groups, err := c.adminRepo.GetAllGroups() + if err != nil { + return fmt.Sprintf("Failed to get groups: %v", err) + } + + if len(groups) == 0 { + return "No groups found." + } + + var sb strings.Builder + sb.WriteString(fmt.Sprintf("*Total groups: %d*\n\n", len(groups))) + for i, groupID := range groups { + sb.WriteString(fmt.Sprintf("%d. %d\n", i+1, groupID)) + } + + return sb.String() +} diff --git a/internal/bot/command/raw_apple_app.go b/internal/bot/command/raw_apple_app.go new file mode 100644 index 0000000..dcdd2f4 --- /dev/null +++ b/internal/bot/command/raw_apple_app.go @@ -0,0 +1,58 @@ +package command + +import ( + "encoding/json" + "fmt" + "strings" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/miti99/store-scraper-bot-go/internal/api/apple" + "github.com/miti99/store-scraper-bot-go/internal/config" +) + +type RawAppleAppCommand struct { + BaseCommand + appleScraper *apple.AppleScraper +} + +func NewRawAppleAppCommand(cfg *config.Config, appleScraper *apple.AppleScraper) *RawAppleAppCommand { + return &RawAppleAppCommand{ + BaseCommand: BaseCommand{cfg: cfg}, + appleScraper: appleScraper, + } +} + +func (c *RawAppleAppCommand) Execute(message *tgbotapi.Message) string { + if !c.requireAdmin(message) { + return "You are not authorized to use this command." + } + + args := strings.Fields(message.CommandArguments()) + if len(args) == 0 { + return "Usage: /rawapple [country]\nExample: /rawapple com.example.app vn" + } + + appID := args[0] + country := "vn" + if len(args) > 1 { + country = args[1] + } + + app, err := c.appleScraper.GetApp(appID, country) + if err != nil { + return fmt.Sprintf("Failed to fetch app: %v", err) + } + + jsonData, err := json.MarshalIndent(app, "", " ") + if err != nil { + return fmt.Sprintf("Failed to marshal JSON: %v", err) + } + + // Telegram has a message size limit, so we might need to truncate + jsonStr := string(jsonData) + if len(jsonStr) > 4000 { + jsonStr = jsonStr[:4000] + "\n...(truncated)" + } + + return fmt.Sprintf("```json\n%s\n```", jsonStr) +} diff --git a/internal/bot/command/raw_google_app.go b/internal/bot/command/raw_google_app.go new file mode 100644 index 0000000..f4f37db --- /dev/null +++ b/internal/bot/command/raw_google_app.go @@ -0,0 +1,58 @@ +package command + +import ( + "encoding/json" + "fmt" + "strings" + + tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5" + "github.com/miti99/store-scraper-bot-go/internal/api/google" + "github.com/miti99/store-scraper-bot-go/internal/config" +) + +type RawGoogleAppCommand struct { + BaseCommand + googleScraper *google.GoogleScraper +} + +func NewRawGoogleAppCommand(cfg *config.Config, googleScraper *google.GoogleScraper) *RawGoogleAppCommand { + return &RawGoogleAppCommand{ + BaseCommand: BaseCommand{cfg: cfg}, + googleScraper: googleScraper, + } +} + +func (c *RawGoogleAppCommand) Execute(message *tgbotapi.Message) string { + if !c.requireAdmin(message) { + return "You are not authorized to use this command." + } + + args := strings.Fields(message.CommandArguments()) + if len(args) == 0 { + return "Usage: /rawgoogle [country]\nExample: /rawgoogle com.example.app vn" + } + + appID := args[0] + country := "vn" + if len(args) > 1 { + country = args[1] + } + + app, err := c.googleScraper.GetApp(appID, country) + if err != nil { + return fmt.Sprintf("Failed to fetch app: %v", err) + } + + jsonData, err := json.MarshalIndent(app, "", " ") + if err != nil { + return fmt.Sprintf("Failed to marshal JSON: %v", err) + } + + // Telegram has a message size limit, so we might need to truncate + jsonStr := string(jsonData) + if len(jsonStr) > 4000 { + jsonStr = jsonStr[:4000] + "\n...(truncated)" + } + + return fmt.Sprintf("```json\n%s\n```", jsonStr) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..3a4b4d7 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,153 @@ +package config + +import ( + "fmt" + "os" + "strconv" + "strings" + "time" + + "go.uber.org/zap" +) + +type Environment string + +const ( + Development Environment = "DEVELOPMENT" + Production Environment = "PRODUCTION" +) + +type Config struct { + // Telegram + TelegramBotToken string + TelegramBotUsername string + + // MongoDB + MongoURI string + MongoDatabase string + MongoTimeout time.Duration + + // Application + Env Environment + AdminIDs []int64 + CreatorID int64 + SourceCommit string + + // Constants + AppCacheSeconds int + NumDaysWarningNotUpdated int + ScheduleCheckAppTime string + VietnamLocation *time.Location + + // Logger + Logger *zap.Logger +} + +var GlobalConfig *Config + +func Load() (*Config, error) { + cfg := &Config{} + + // Telegram + cfg.TelegramBotToken = getEnv("TELEGRAM_BOT_TOKEN", "") + if cfg.TelegramBotToken == "" { + return nil, fmt.Errorf("TELEGRAM_BOT_TOKEN is required") + } + cfg.TelegramBotUsername = getEnv("TELEGRAM_BOT_USERNAME", "") + if cfg.TelegramBotUsername == "" { + return nil, fmt.Errorf("TELEGRAM_BOT_USERNAME is required") + } + + // MongoDB + cfg.MongoURI = getEnv("MONGO_URI", "mongodb://localhost:27017") + cfg.MongoDatabase = getEnv("MONGO_DATABASE", "store_scraper_bot") + cfg.MongoTimeout = time.Duration(getEnvInt("MONGO_TIMEOUT_SECONDS", 10)) * time.Second + + // Application + envStr := getEnv("ENV", "DEVELOPMENT") + if envStr == "PRODUCTION" { + cfg.Env = Production + } else { + cfg.Env = Development + } + + adminIDsStr := getEnv("ADMIN_IDS", "") + if adminIDsStr == "" { + return nil, fmt.Errorf("ADMIN_IDS is required") + } + cfg.AdminIDs = parseAdminIDs(adminIDsStr) + if len(cfg.AdminIDs) == 0 { + return nil, fmt.Errorf("at least one admin ID is required") + } + cfg.CreatorID = cfg.AdminIDs[0] + + cfg.SourceCommit = getEnv("SOURCE_COMMIT", "unknown") + + // Constants + cfg.AppCacheSeconds = getEnvInt("APP_CACHE_SECONDS", 600) + cfg.NumDaysWarningNotUpdated = getEnvInt("NUM_DAYS_WARNING_NOT_UPDATED", 30) + cfg.ScheduleCheckAppTime = getEnv("SCHEDULE_CHECK_APP_TIME", "0 7 * * *") // Cron format: 7:00 AM daily + + // Vietnam timezone + loc, err := time.LoadLocation("Asia/Ho_Chi_Minh") + if err != nil { + return nil, fmt.Errorf("failed to load Vietnam timezone: %w", err) + } + cfg.VietnamLocation = loc + + // Initialize logger + var logger *zap.Logger + if cfg.Env == Production { + logger, err = zap.NewProduction() + } else { + logger, err = zap.NewDevelopment() + } + if err != nil { + return nil, fmt.Errorf("failed to initialize logger: %w", err) + } + cfg.Logger = logger + + GlobalConfig = cfg + return cfg, nil +} + +func getEnv(key, defaultValue string) string { + if value := os.Getenv(key); value != "" { + return value + } + return defaultValue +} + +func getEnvInt(key string, defaultValue int) int { + if value := os.Getenv(key); value != "" { + if intVal, err := strconv.Atoi(value); err == nil { + return intVal + } + } + return defaultValue +} + +func parseAdminIDs(adminIDsStr string) []int64 { + parts := strings.Split(adminIDsStr, ",") + adminIDs := make([]int64, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if id, err := strconv.ParseInt(part, 10, 64); err == nil { + adminIDs = append(adminIDs, id) + } + } + return adminIDs +} + +func (c *Config) IsAdmin(userID int64) bool { + for _, adminID := range c.AdminIDs { + if adminID == userID { + return true + } + } + return false +} + +func (c *Config) GetScopeName() string { + return strings.ToLower(string(c.Env)) +} diff --git a/internal/model/admin.go b/internal/model/admin.go new file mode 100644 index 0000000..1497ac4 --- /dev/null +++ b/internal/model/admin.go @@ -0,0 +1,42 @@ +package model + +type Admin struct { + Key string `bson:"_id,omitempty" json:"key"` + Groups []int64 `bson:"groups" json:"groups"` +} + +func NewAdmin() *Admin { + return &Admin{ + Key: "admin", + Groups: make([]int64, 0), + } +} + +func (a *Admin) AddGroup(groupID int64) bool { + for _, g := range a.Groups { + if g == groupID { + return false // Already exists + } + } + a.Groups = append(a.Groups, groupID) + return true +} + +func (a *Admin) RemoveGroup(groupID int64) bool { + for i, g := range a.Groups { + if g == groupID { + a.Groups = append(a.Groups[:i], a.Groups[i+1:]...) + return true + } + } + return false +} + +func (a *Admin) HasGroup(groupID int64) bool { + for _, g := range a.Groups { + if g == groupID { + return true + } + } + return false +} diff --git a/internal/model/apple_app.go b/internal/model/apple_app.go new file mode 100644 index 0000000..549c3b3 --- /dev/null +++ b/internal/model/apple_app.go @@ -0,0 +1,49 @@ +package model + +import "time" + +type AppleApp struct { + Key string `bson:"_id" json:"key"` + App AppleAppResponse `bson:"app" json:"app"` + UpdatedAt time.Time `bson:"updatedAt" json:"updatedAt"` +} + +type AppleAppResponse struct { + ID int64 `json:"id"` + AppID string `json:"appId"` + Title string `json:"title"` + URL string `json:"url"` + Description string `json:"description"` + Icon string `json:"icon"` + Genres []string `json:"genres"` + PrimaryGenre string `json:"primaryGenre"` + ContentRating string `json:"contentRating"` + Size string `json:"size"` + RequiredOsVersion string `json:"requiredOsVersion"` + Released string `json:"released"` + Updated string `json:"updated"` // ISO 8601 timestamp + Version string `json:"version"` + Price float64 `json:"price"` + Currency string `json:"currency"` + Free bool `json:"free"` + DeveloperID int64 `json:"developerId"` + Developer string `json:"developer"` + DeveloperURL string `json:"developerUrl"` + Score float64 `json:"score"` + Reviews int `json:"reviews"` + Ratings int64 `json:"ratings"` + Screenshots []string `json:"screenshots"` + Histogram map[string]int64 `json:"histogram"` +} + +func NewAppleApp(appID string, response AppleAppResponse) *AppleApp { + return &AppleApp{ + Key: appID, + App: response, + UpdatedAt: time.Now(), + } +} + +func (a *AppleApp) IsExpired(cacheSeconds int) bool { + return time.Since(a.UpdatedAt).Seconds() > float64(cacheSeconds) +} diff --git a/internal/model/google_app.go b/internal/model/google_app.go new file mode 100644 index 0000000..c166c99 --- /dev/null +++ b/internal/model/google_app.go @@ -0,0 +1,48 @@ +package model + +import "time" + +type GoogleApp struct { + Key string `bson:"_id" json:"key"` + App GoogleAppResponse `bson:"app" json:"app"` + UpdatedAt time.Time `bson:"updatedAt" json:"updatedAt"` +} + +type GoogleAppResponse struct { + Title string `json:"title"` + Description string `json:"description"` + Installs string `json:"installs"` + MinInstalls int64 `json:"minInstalls"` + MaxInstalls int64 `json:"maxInstalls"` + Score float64 `json:"score"` + ScoreText string `json:"scoreText"` + Ratings int64 `json:"ratings"` + Reviews int64 `json:"reviews"` + Histogram map[string]int64 `json:"histogram"` + Price float64 `json:"price"` + Free bool `json:"free"` + Currency string `json:"currency"` + Developer string `json:"developer"` + Genre string `json:"genre"` + Icon string `json:"icon"` + HeaderImage string `json:"headerImage"` + Screenshots []string `json:"screenshots"` + ContentRating string `json:"contentRating"` + AdSupported bool `json:"adSupported"` + Updated int64 `json:"updated"` // Milliseconds since epoch + Version string `json:"version"` + AppID string `json:"appId"` + URL string `json:"url"` +} + +func NewGoogleApp(appID string, response GoogleAppResponse) *GoogleApp { + return &GoogleApp{ + Key: appID, + App: response, + UpdatedAt: time.Now(), + } +} + +func (g *GoogleApp) IsExpired(cacheSeconds int) bool { + return time.Since(g.UpdatedAt).Seconds() > float64(cacheSeconds) +} diff --git a/internal/model/group.go b/internal/model/group.go new file mode 100644 index 0000000..8f25926 --- /dev/null +++ b/internal/model/group.go @@ -0,0 +1,60 @@ +package model + +type AppInfo struct { + AppID string `bson:"appId" json:"appId"` + Country string `bson:"country" json:"country"` +} + +type Group struct { + Key int64 `bson:"_id" json:"key"` + AppleApps []AppInfo `bson:"appleApps" json:"appleApps"` + GoogleApps []AppInfo `bson:"googleApps" json:"googleApps"` +} + +func NewGroup(groupID int64) *Group { + return &Group{ + Key: groupID, + AppleApps: make([]AppInfo, 0), + GoogleApps: make([]AppInfo, 0), + } +} + +func (g *Group) AddAppleApp(appID, country string) bool { + for _, app := range g.AppleApps { + if app.AppID == appID && app.Country == country { + return false // Already exists + } + } + g.AppleApps = append(g.AppleApps, AppInfo{AppID: appID, Country: country}) + return true +} + +func (g *Group) RemoveAppleApp(appID string) bool { + for i, app := range g.AppleApps { + if app.AppID == appID { + g.AppleApps = append(g.AppleApps[:i], g.AppleApps[i+1:]...) + return true + } + } + return false +} + +func (g *Group) AddGoogleApp(appID, country string) bool { + for _, app := range g.GoogleApps { + if app.AppID == appID && app.Country == country { + return false // Already exists + } + } + g.GoogleApps = append(g.GoogleApps, AppInfo{AppID: appID, Country: country}) + return true +} + +func (g *Group) RemoveGoogleApp(appID string) bool { + for i, app := range g.GoogleApps { + if app.AppID == appID { + g.GoogleApps = append(g.GoogleApps[:i], g.GoogleApps[i+1:]...) + return true + } + } + return false +} diff --git a/internal/model/non_updated_app.go b/internal/model/non_updated_app.go new file mode 100644 index 0000000..0573a4e --- /dev/null +++ b/internal/model/non_updated_app.go @@ -0,0 +1,12 @@ +package model + +type NonUpdatedApp struct { + AppID string + Title string + Days int + Updated string + Score float64 + Reviews interface{} // Can be int or string + Ratings int64 + IsApple bool +} diff --git a/internal/repository/admin_repository.go b/internal/repository/admin_repository.go new file mode 100644 index 0000000..5afb5bd --- /dev/null +++ b/internal/repository/admin_repository.go @@ -0,0 +1,100 @@ +package repository + +import ( + "context" + "fmt" + "time" + + "github.com/miti99/store-scraper-bot-go/internal/model" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type AdminRepository struct { + collection *mongo.Collection +} + +func NewAdminRepository() *AdminRepository { + return &AdminRepository{ + collection: GetCollection("admin"), + } +} + +func (r *AdminRepository) Get(ctx context.Context) (*model.Admin, error) { + admin := &model.Admin{} + err := r.collection.FindOne(ctx, bson.M{"_id": "admin"}).Decode(admin) + if err != nil { + if err == mongo.ErrNoDocuments { + // Return new admin if not found + return model.NewAdmin(), nil + } + return nil, fmt.Errorf("failed to get admin: %w", err) + } + return admin, nil +} + +func (r *AdminRepository) Save(ctx context.Context, admin *model.Admin) error { + opts := options.Replace().SetUpsert(true) + _, err := r.collection.ReplaceOne(ctx, bson.M{"_id": "admin"}, admin, opts) + if err != nil { + return fmt.Errorf("failed to save admin: %w", err) + } + return nil +} + +func (r *AdminRepository) AddGroup(groupID int64) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + admin, err := r.Get(ctx) + if err != nil { + return err + } + + if !admin.AddGroup(groupID) { + return fmt.Errorf("group already exists") + } + + return r.Save(ctx, admin) +} + +func (r *AdminRepository) RemoveGroup(groupID int64) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + admin, err := r.Get(ctx) + if err != nil { + return err + } + + if !admin.RemoveGroup(groupID) { + return fmt.Errorf("group not found") + } + + return r.Save(ctx, admin) +} + +func (r *AdminRepository) HasGroup(groupID int64) (bool, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + admin, err := r.Get(ctx) + if err != nil { + return false, err + } + + return admin.HasGroup(groupID), nil +} + +func (r *AdminRepository) GetAllGroups() ([]int64, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + admin, err := r.Get(ctx) + if err != nil { + return nil, err + } + + return admin.Groups, nil +} diff --git a/internal/repository/apple_app_repository.go b/internal/repository/apple_app_repository.go new file mode 100644 index 0000000..f0ce0c8 --- /dev/null +++ b/internal/repository/apple_app_repository.go @@ -0,0 +1,61 @@ +package repository + +import ( + "context" + "fmt" + "time" + + "github.com/miti99/store-scraper-bot-go/internal/config" + "github.com/miti99/store-scraper-bot-go/internal/model" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type AppleAppRepository struct { + collection *mongo.Collection +} + +func NewAppleAppRepository() *AppleAppRepository { + return &AppleAppRepository{ + collection: GetCollection("apple_app"), + } +} + +func (r *AppleAppRepository) Get(ctx context.Context, appID string) (*model.AppleApp, error) { + app := &model.AppleApp{} + err := r.collection.FindOne(ctx, bson.M{"_id": appID}).Decode(app) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, nil // Not found + } + return nil, fmt.Errorf("failed to get apple app: %w", err) + } + return app, nil +} + +func (r *AppleAppRepository) Save(ctx context.Context, app *model.AppleApp) error { + app.UpdatedAt = time.Now() + opts := options.Replace().SetUpsert(true) + _, err := r.collection.ReplaceOne(ctx, bson.M{"_id": app.Key}, app, opts) + if err != nil { + return fmt.Errorf("failed to save apple app: %w", err) + } + return nil +} + +func (r *AppleAppRepository) GetCached(appID string) (*model.AppleApp, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + app, err := r.Get(ctx, appID) + if err != nil { + return nil, err + } + + if app != nil && !app.IsExpired(config.GlobalConfig.AppCacheSeconds) { + return app, nil + } + + return nil, nil // Cache expired or not found +} diff --git a/internal/repository/google_app_repository.go b/internal/repository/google_app_repository.go new file mode 100644 index 0000000..1c44c00 --- /dev/null +++ b/internal/repository/google_app_repository.go @@ -0,0 +1,61 @@ +package repository + +import ( + "context" + "fmt" + "time" + + "github.com/miti99/store-scraper-bot-go/internal/config" + "github.com/miti99/store-scraper-bot-go/internal/model" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type GoogleAppRepository struct { + collection *mongo.Collection +} + +func NewGoogleAppRepository() *GoogleAppRepository { + return &GoogleAppRepository{ + collection: GetCollection("google_app"), + } +} + +func (r *GoogleAppRepository) Get(ctx context.Context, appID string) (*model.GoogleApp, error) { + app := &model.GoogleApp{} + err := r.collection.FindOne(ctx, bson.M{"_id": appID}).Decode(app) + if err != nil { + if err == mongo.ErrNoDocuments { + return nil, nil // Not found + } + return nil, fmt.Errorf("failed to get google app: %w", err) + } + return app, nil +} + +func (r *GoogleAppRepository) Save(ctx context.Context, app *model.GoogleApp) error { + app.UpdatedAt = time.Now() + opts := options.Replace().SetUpsert(true) + _, err := r.collection.ReplaceOne(ctx, bson.M{"_id": app.Key}, app, opts) + if err != nil { + return fmt.Errorf("failed to save google app: %w", err) + } + return nil +} + +func (r *GoogleAppRepository) GetCached(appID string) (*model.GoogleApp, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + app, err := r.Get(ctx, appID) + if err != nil { + return nil, err + } + + if app != nil && !app.IsExpired(config.GlobalConfig.AppCacheSeconds) { + return app, nil + } + + return nil, nil // Cache expired or not found +} diff --git a/internal/repository/group_repository.go b/internal/repository/group_repository.go new file mode 100644 index 0000000..47277c7 --- /dev/null +++ b/internal/repository/group_repository.go @@ -0,0 +1,116 @@ +package repository + +import ( + "context" + "fmt" + "time" + + "github.com/miti99/store-scraper-bot-go/internal/model" + "go.mongodb.org/mongo-driver/bson" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" +) + +type GroupRepository struct { + collection *mongo.Collection +} + +func NewGroupRepository() *GroupRepository { + return &GroupRepository{ + collection: GetCollection("group"), + } +} + +func (r *GroupRepository) Get(ctx context.Context, groupID int64) (*model.Group, error) { + group := &model.Group{} + err := r.collection.FindOne(ctx, bson.M{"_id": groupID}).Decode(group) + if err != nil { + if err == mongo.ErrNoDocuments { + // Return new group if not found + return model.NewGroup(groupID), nil + } + return nil, fmt.Errorf("failed to get group: %w", err) + } + return group, nil +} + +func (r *GroupRepository) Save(ctx context.Context, group *model.Group) error { + opts := options.Replace().SetUpsert(true) + _, err := r.collection.ReplaceOne(ctx, bson.M{"_id": group.Key}, group, opts) + if err != nil { + return fmt.Errorf("failed to save group: %w", err) + } + return nil +} + +func (r *GroupRepository) Delete(ctx context.Context, groupID int64) error { + _, err := r.collection.DeleteOne(ctx, bson.M{"_id": groupID}) + if err != nil { + return fmt.Errorf("failed to delete group: %w", err) + } + return nil +} + +func (r *GroupRepository) AddAppleApp(groupID int64, appID, country string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + group, err := r.Get(ctx, groupID) + if err != nil { + return err + } + + if !group.AddAppleApp(appID, country) { + return fmt.Errorf("apple app already exists in group") + } + + return r.Save(ctx, group) +} + +func (r *GroupRepository) RemoveAppleApp(groupID int64, appID string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + group, err := r.Get(ctx, groupID) + if err != nil { + return err + } + + if !group.RemoveAppleApp(appID) { + return fmt.Errorf("apple app not found in group") + } + + return r.Save(ctx, group) +} + +func (r *GroupRepository) AddGoogleApp(groupID int64, appID, country string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + group, err := r.Get(ctx, groupID) + if err != nil { + return err + } + + if !group.AddGoogleApp(appID, country) { + return fmt.Errorf("google app already exists in group") + } + + return r.Save(ctx, group) +} + +func (r *GroupRepository) RemoveGoogleApp(groupID int64, appID string) error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + group, err := r.Get(ctx, groupID) + if err != nil { + return err + } + + if !group.RemoveGoogleApp(appID) { + return fmt.Errorf("google app not found in group") + } + + return r.Save(ctx, group) +} diff --git a/internal/repository/mongodb.go b/internal/repository/mongodb.go new file mode 100644 index 0000000..a154d03 --- /dev/null +++ b/internal/repository/mongodb.go @@ -0,0 +1,59 @@ +package repository + +import ( + "context" + "fmt" + "time" + + "github.com/miti99/store-scraper-bot-go/internal/config" + "go.mongodb.org/mongo-driver/mongo" + "go.mongodb.org/mongo-driver/mongo/options" + "go.uber.org/zap" +) + +var ( + client *mongo.Client + database *mongo.Database +) + +func InitMongoDB(cfg *config.Config) error { + ctx, cancel := context.WithTimeout(context.Background(), cfg.MongoTimeout) + defer cancel() + + clientOptions := options.Client().ApplyURI(cfg.MongoURI) + + var err error + client, err = mongo.Connect(ctx, clientOptions) + if err != nil { + return fmt.Errorf("failed to connect to MongoDB: %w", err) + } + + // Ping to verify connection + if err := client.Ping(ctx, nil); err != nil { + return fmt.Errorf("failed to ping MongoDB: %w", err) + } + + database = client.Database(cfg.MongoDatabase) + cfg.Logger.Info("Connected to MongoDB", + zap.String("database", cfg.MongoDatabase), + zap.String("uri", cfg.MongoURI)) + + return nil +} + +func Close() error { + if client != nil { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + return client.Disconnect(ctx) + } + return nil +} + +func GetDatabase() *mongo.Database { + return database +} + +func GetCollection(name string) *mongo.Collection { + return database.Collection(name) +} diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go new file mode 100644 index 0000000..202c769 --- /dev/null +++ b/internal/scheduler/scheduler.go @@ -0,0 +1,232 @@ +package scheduler + +import ( + "context" + "fmt" + "time" + + "github.com/miti99/store-scraper-bot-go/internal/api/apple" + "github.com/miti99/store-scraper-bot-go/internal/api/google" + "github.com/miti99/store-scraper-bot-go/internal/bot" + "github.com/miti99/store-scraper-bot-go/internal/config" + "github.com/miti99/store-scraper-bot-go/internal/model" + "github.com/miti99/store-scraper-bot-go/internal/repository" + "github.com/miti99/store-scraper-bot-go/internal/util" + "github.com/robfig/cron/v3" + "go.uber.org/zap" +) + +type Scheduler struct { + cron *cron.Cron + cfg *config.Config + bot *bot.Bot + adminRepo *repository.AdminRepository + groupRepo *repository.GroupRepository + appleScraper *apple.AppleScraper + googleScraper *google.GoogleScraper + logger *zap.Logger +} + +func NewScheduler( + cfg *config.Config, + bot *bot.Bot, + adminRepo *repository.AdminRepository, + groupRepo *repository.GroupRepository, + appleScraper *apple.AppleScraper, + googleScraper *google.GoogleScraper, +) *Scheduler { + // Create cron with Vietnam timezone + c := cron.New(cron.WithLocation(cfg.VietnamLocation)) + + return &Scheduler{ + cron: c, + cfg: cfg, + bot: bot, + adminRepo: adminRepo, + groupRepo: groupRepo, + appleScraper: appleScraper, + googleScraper: googleScraper, + logger: cfg.Logger, + } +} + +func (s *Scheduler) Start() error { + // Schedule daily check at configured time (default: 7:00 AM Vietnam time) + _, err := s.cron.AddFunc(s.cfg.ScheduleCheckAppTime, s.runDailyCheck) + if err != nil { + return fmt.Errorf("failed to schedule daily check: %w", err) + } + + s.logger.Info("Scheduler started", + zap.String("schedule", s.cfg.ScheduleCheckAppTime), + zap.String("timezone", s.cfg.VietnamLocation.String())) + + s.cron.Start() + return nil +} + +func (s *Scheduler) Stop() { + s.cron.Stop() + s.logger.Info("Scheduler stopped") +} + +func (s *Scheduler) runDailyCheck() { + s.logger.Info("Running daily check job") + + now := time.Now().In(s.cfg.VietnamLocation) + + // Check if today is weekend (Saturday or Sunday) + isWeekend := now.Weekday() == time.Saturday || now.Weekday() == time.Sunday + + groups, err := s.adminRepo.GetAllGroups() + if err != nil { + s.logger.Error("Failed to get groups for daily check", zap.Error(err)) + return + } + + for _, groupID := range groups { + s.checkGroup(groupID, isWeekend) + } + + s.logger.Info("Daily check job completed", zap.Int("groupsChecked", len(groups))) +} + +func (s *Scheduler) checkGroup(groupID int64, isWeekend bool) { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + group, err := s.groupRepo.Get(ctx, groupID) + if err != nil { + s.logger.Error("Failed to get group", zap.Int64("groupId", groupID), zap.Error(err)) + return + } + + if len(group.AppleApps) == 0 && len(group.GoogleApps) == 0 { + s.logger.Info("Group has no apps, skipping", zap.Int64("groupId", groupID)) + return + } + + nonUpdatedApps := make([]model.NonUpdatedApp, 0) + now := time.Now().In(s.cfg.VietnamLocation) + + // Check Apple apps + for _, appInfo := range group.AppleApps { + app, err := s.appleScraper.GetApp(appInfo.AppID, appInfo.Country) + if err != nil { + s.logger.Error("Failed to fetch Apple app", + zap.Int64("groupId", groupID), + zap.String("appId", appInfo.AppID), + zap.Error(err)) + continue + } + + updatedTime, err := time.Parse(time.RFC3339, app.Updated) + if err != nil { + s.logger.Error("Failed to parse update time", + zap.Int64("groupId", groupID), + zap.String("appId", appInfo.AppID), + zap.String("updated", app.Updated), + zap.Error(err)) + continue + } + + daysSinceUpdate := int(now.Sub(updatedTime).Hours() / 24) + if daysSinceUpdate > s.cfg.NumDaysWarningNotUpdated { + nonUpdatedApps = append(nonUpdatedApps, model.NonUpdatedApp{ + AppID: appInfo.AppID, + Title: app.Title, + Days: daysSinceUpdate, + Updated: app.Updated[:10], + Score: app.Score, + Reviews: app.Reviews, + Ratings: app.Ratings, + IsApple: true, + }) + } + } + + // Check Google apps + for _, appInfo := range group.GoogleApps { + app, err := s.googleScraper.GetApp(appInfo.AppID, appInfo.Country) + if err != nil { + s.logger.Error("Failed to fetch Google app", + zap.Int64("groupId", groupID), + zap.String("appId", appInfo.AppID), + zap.Error(err)) + continue + } + + updatedTime := time.UnixMilli(app.Updated) + daysSinceUpdate := int(now.Sub(updatedTime).Hours() / 24) + + if daysSinceUpdate > s.cfg.NumDaysWarningNotUpdated { + nonUpdatedApps = append(nonUpdatedApps, model.NonUpdatedApp{ + AppID: appInfo.AppID, + Title: app.Title, + Days: daysSinceUpdate, + Updated: updatedTime.Format("2006-01-02"), + Score: app.Score, + Reviews: app.Reviews, + Ratings: app.Ratings, + IsApple: false, + }) + } + } + + // Send report + if len(nonUpdatedApps) == 0 { + s.logger.Info("No non-updated apps found for group", zap.Int64("groupId", groupID)) + return + } + + message := s.buildReport(groupID, nonUpdatedApps) + + var err2 error + if isWeekend { + err2 = s.bot.SendMessageSilent(groupID, message) + } else { + err2 = s.bot.SendMessage(groupID, message) + } + + if err2 != nil { + s.logger.Error("Failed to send daily check report", + zap.Int64("groupId", groupID), + zap.Error(err2)) + } else { + s.logger.Info("Daily check report sent", + zap.Int64("groupId", groupID), + zap.Int("nonUpdatedApps", len(nonUpdatedApps)), + zap.Bool("silent", isWeekend)) + } +} + +func (s *Scheduler) buildReport(groupID int64, nonUpdatedApps []model.NonUpdatedApp) string { + var rows [][]string + for _, app := range nonUpdatedApps { + store := "Google" + if app.IsApple { + store = "Apple" + } + + rows = append(rows, []string{ + util.TruncateString(app.Title, 30), + store, + fmt.Sprintf("%d", app.Days), + app.Updated, + fmt.Sprintf("%.1f", app.Score), + fmt.Sprintf("%v", app.Reviews), + util.FormatNumber(app.Ratings), + }) + } + + headers := []string{"App", "Store", "Days", "Updated", "Score", "Reviews", "Ratings"} + table := util.BuildTable(headers, rows) + + now := time.Now().In(s.cfg.VietnamLocation) + return fmt.Sprintf("*Daily App Check Report*\nDate: %s\nGroup: %d\nApps not updated in >%d days: *%d*\n\n%s", + now.Format("2006-01-02 15:04"), + groupID, + s.cfg.NumDaysWarningNotUpdated, + len(nonUpdatedApps), + table) +} diff --git a/internal/util/table.go b/internal/util/table.go new file mode 100644 index 0000000..6906509 --- /dev/null +++ b/internal/util/table.go @@ -0,0 +1,91 @@ +package util + +import ( + "fmt" + "strings" +) + +func BuildTable(headers []string, rows [][]string) string { + if len(rows) == 0 { + return "" + } + + // Calculate column widths + columnWidths := make([]int, len(headers)) + for i, header := range headers { + columnWidths[i] = len(header) + } + + for _, row := range rows { + for i, cell := range row { + if i < len(columnWidths) && len(cell) > columnWidths[i] { + columnWidths[i] = len(cell) + } + } + } + + // Build table + var sb strings.Builder + + // Top border + sb.WriteString("```\n") + + // Header + for i, header := range headers { + sb.WriteString(padRight(header, columnWidths[i])) + if i < len(headers)-1 { + sb.WriteString(" | ") + } + } + sb.WriteString("\n") + + // Separator + for i, width := range columnWidths { + sb.WriteString(strings.Repeat("-", width)) + if i < len(columnWidths)-1 { + sb.WriteString("-+-") + } + } + sb.WriteString("\n") + + // Rows + for _, row := range rows { + for i, cell := range row { + if i < len(columnWidths) { + sb.WriteString(padRight(cell, columnWidths[i])) + if i < len(row)-1 { + sb.WriteString(" | ") + } + } + } + sb.WriteString("\n") + } + + // Bottom border + sb.WriteString("```") + + return sb.String() +} + +func padRight(s string, length int) string { + if len(s) >= length { + return s + } + return s + strings.Repeat(" ", length-len(s)) +} + +func TruncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen-3] + "..." +} + +func FormatNumber(n int64) string { + if n >= 1000000 { + return fmt.Sprintf("%.1fM", float64(n)/1000000) + } else if n >= 1000 { + return fmt.Sprintf("%.1fK", float64(n)/1000) + } + return fmt.Sprintf("%d", n) +}