mirror of
https://github.com/tiennm99/store-scraper-bot.git
synced 2026-09-05 12:17:14 +00:00
feat(core): add logic converted from original repo by Claude Code
This commit is contained in:
@@ -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.*
|
||||
@@ -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 * * *
|
||||
+31
@@ -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"]
|
||||
@@ -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...")
|
||||
}
|
||||
@@ -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:
|
||||
@@ -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:
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 <appId> [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)
|
||||
}
|
||||
@@ -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 <appId> [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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 <appId>\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)
|
||||
}
|
||||
@@ -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 <appId>\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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 <appId> [country] - Add Apple app
|
||||
/deleteapple <appId> - Remove Apple app
|
||||
/addgoogle <appId> [country] - Add Google app
|
||||
/deletegoogle <appId> - Remove Google app
|
||||
/listapp - List apps in current group
|
||||
/checkapp - Check for non-updated apps
|
||||
/checkappscores - Check app scores
|
||||
/rawapple <appId> [country] - Get raw Apple data
|
||||
/rawgoogle <appId> [country] - Get raw Google data
|
||||
/info - Show this info`,
|
||||
c.cfg.Env,
|
||||
c.cfg.SourceCommit,
|
||||
runtime.Version(),
|
||||
c.cfg.TelegramBotUsername,
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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 <appId> [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)
|
||||
}
|
||||
@@ -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 <appId> [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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user