From e695d9b22313a55261daba28552eaaba86af0af5 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 25 Apr 2026 20:26:25 +0700 Subject: [PATCH] feat: port logic from Java original to align with current Java behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Switch Telegram parse mode to HTML to match Java client. - Rename command identifiers to Java's: delgroup/delapple/delgoogle/ checkappscore/rawappleapp/rawgoogleapp. - Move admin singleton to "common" collection at _id="admin". - Store group _id as string-of-int64 to match Java AbstractModel schema. - Add `class` discriminator field on persisted models. - Expand AppleAppResponse (42 fields) and GoogleAppResponse (63 fields, with nested Category/Feature) to match Java records. - Add AppleAppRequest (track id or bundle id) / GoogleAppRequest types. - Implement Raw* commands as Telegram document attachments instead of truncated text. - Group-authorization gate (admin.HasGroup(chatId)) on non-admin commands. - Cache TTL via millis timestamp; round score to 1 decimal; weekend silent send in scheduler. - Match Java table renderer: " │ " separators, "─┼─" rows every 5 lines. - Prefer MONGODB_CONNECTION_STRING env (Java parity); auto-extract DB name from URI with fallback to "store-scraper-bot". Note: This is an AI-assisted port (not human-verified). Behavior parity with the Java implementation has not been tested end-to-end; treat as a starting point. --- cmd/bot/main.go | 5 + internal/api/apple/apple_scraper.go | 154 ++++++------- .../api/apple/request/apple_app_request.go | 18 ++ internal/api/google/google_scraper.go | 154 ++++++------- .../api/google/request/google_app_request.go | 14 ++ internal/bot/bot.go | 139 ++++++------ internal/bot/command/add_apple_app.go | 82 ++++--- internal/bot/command/add_google_app.go | 72 +++--- internal/bot/command/add_group.go | 51 +++-- internal/bot/command/args.go | 13 ++ internal/bot/command/check_app.go | 213 +++++++----------- internal/bot/command/check_app_scores.go | 153 ++++++------- internal/bot/command/command.go | 32 ++- internal/bot/command/delete_apple_app.go | 49 ++-- internal/bot/command/delete_google_app.go | 49 ++-- internal/bot/command/delete_group.go | 54 +++-- internal/bot/command/info.go | 47 +--- internal/bot/command/list_app.go | 68 +++--- internal/bot/command/list_group.go | 36 +-- internal/bot/command/raw_apple_app.go | 58 ++--- internal/bot/command/raw_google_app.go | 54 ++--- internal/config/config.go | 65 ++++-- internal/model/abstract_model.go | 8 + internal/model/admin.go | 17 +- internal/model/apple_app.go | 86 +++---- internal/model/google_app.go | 117 +++++++--- internal/model/group.go | 40 +++- internal/model/non_updated_app.go | 4 +- internal/repository/admin_repository.go | 46 ++-- internal/repository/apple_app_repository.go | 24 +- internal/repository/google_app_repository.go | 21 +- internal/repository/group_repository.go | 83 ++++--- internal/scheduler/scheduler.go | 153 +++++-------- internal/util/table.go | 102 +++++---- 34 files changed, 1132 insertions(+), 1149 deletions(-) create mode 100644 internal/api/apple/request/apple_app_request.go create mode 100644 internal/api/google/request/google_app_request.go create mode 100644 internal/bot/command/args.go create mode 100644 internal/model/abstract_model.go diff --git a/cmd/bot/main.go b/cmd/bot/main.go index d5d79a6..2d48a88 100644 --- a/cmd/bot/main.go +++ b/cmd/bot/main.go @@ -39,6 +39,11 @@ func main() { appleAppRepo := repository.NewAppleAppRepository() googleAppRepo := repository.NewGoogleAppRepository() + // Java parity: ensure the singleton "common/admin" document exists. + if err := adminRepo.Init(); err != nil { + cfg.Logger.Fatal("Failed to init admin singleton", zap.Error(err)) + } + // Initialize scrapers appleScraper := apple.NewAppleScraper(appleAppRepo, cfg) googleScraper := google.NewGoogleScraper(googleAppRepo, cfg) diff --git a/internal/api/apple/apple_scraper.go b/internal/api/apple/apple_scraper.go index 8308d7c..3300cd2 100644 --- a/internal/api/apple/apple_scraper.go +++ b/internal/api/apple/apple_scraper.go @@ -5,111 +5,107 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "time" + "github.com/miti99/store-scraper-bot-go/internal/api/apple/request" "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"` -} +// BaseURL mirrors Java AppStoreScraper (api/apple/AppStoreScraper.java). +const BaseURL = "https://store-scraper.vercel.app/apple" type AppleScraper struct { - httpClient *http.Client - appRepo *repository.AppleAppRepository - logger *zap.Logger + repo *repository.AppleAppRepository + cfg *config.Config + client *http.Client + logger *zap.Logger } -func NewAppleScraper(appRepo *repository.AppleAppRepository, cfg *config.Config) *AppleScraper { +func NewAppleScraper(repo *repository.AppleAppRepository, cfg *config.Config) *AppleScraper { return &AppleScraper{ - httpClient: &http.Client{ - Timeout: 30 * time.Second, - }, - appRepo: appRepo, - logger: cfg.Logger, + repo: repo, + cfg: cfg, + client: &http.Client{Timeout: 30 * time.Second}, + logger: cfg.Logger, } } -func (s *AppleScraper) GetApp(appID, country string) (*model.AppleAppResponse, error) { - // Check cache first - cachedApp, err := s.appRepo.GetCached(appID) +// RawApp posts the request and returns the raw JSON body. +func (s *AppleScraper) RawApp(req request.AppleAppRequest) (string, error) { + body, err := json.Marshal(req) if err != nil { - s.logger.Error("Failed to get cached apple app", zap.Error(err), zap.String("appId", appID)) + return "", fmt.Errorf("marshal apple request: %w", err) } - if cachedApp != nil { - s.logger.Debug("Returning cached apple app", zap.String("appId", appID)) - return &cachedApp.App, nil + httpReq, err := http.NewRequestWithContext(context.Background(), http.MethodPost, BaseURL+"/app", bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("build apple request: %w", err) } + httpReq.Header.Set("Content-Type", "application/json") - // 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) + resp, err := s.client.Do(httpReq) + if err != nil { + return "", fmt.Errorf("apple HTTP error: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("apple HTTP status %d", resp.StatusCode) + } + raw, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read apple body: %w", err) + } + return string(raw), nil +} + +// App posts the request and decodes the response. +func (s *AppleScraper) App(req request.AppleAppRequest) (*model.AppleAppResponse, error) { + raw, err := s.RawApp(req) if err != nil { return nil, err } + out := &model.AppleAppResponse{} + if err := json.Unmarshal([]byte(raw), out); err != nil { + return nil, fmt.Errorf("decode apple response: %w", err) + } + return out, nil +} - // Save to cache +// GetApp returns a cached response (if fresh) or fetches by bundleId and caches. +func (s *AppleScraper) GetApp(appID, country string) (*model.AppleAppResponse, error) { + if cached, _ := s.repo.GetCached(appID); cached != nil { + return &cached.App, nil + } + resp, err := s.App(request.ByBundleID(appID, country)) + if err != nil { + return nil, err + } + s.cache(resp) + return resp, nil +} + +// FetchAndCache fetches by an arbitrary request (track ID or bundle ID). +func (s *AppleScraper) FetchAndCache(req request.AppleAppRequest) (*model.AppleAppResponse, error) { + resp, err := s.App(req) + if err != nil { + return nil, err + } + s.cache(resp) + return resp, nil +} + +func (s *AppleScraper) cache(resp *model.AppleAppResponse) { + if resp == nil || resp.AppID == "" { + return + } 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)) + entry := model.NewAppleApp(resp.AppID, *resp, time.Now().UnixMilli()) + if err := s.repo.Save(ctx, entry); err != nil { + s.logger.Warn("failed to cache apple app", zap.String("appId", resp.AppID), zap.Error(err)) } - - 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/apple/request/apple_app_request.go b/internal/api/apple/request/apple_app_request.go new file mode 100644 index 0000000..183e0bb --- /dev/null +++ b/internal/api/apple/request/apple_app_request.go @@ -0,0 +1,18 @@ +package request + +// AppleAppRequest mirrors Java AppleAppRequest record. Either ID (iTunes +// trackId) or AppID (bundleId) is set; the other is omitted from JSON. +type AppleAppRequest struct { + ID *int64 `json:"id,omitempty"` + AppID *string `json:"appId,omitempty"` + Country string `json:"country"` + Ratings bool `json:"ratings"` +} + +func ByTrackID(id int64, country string) AppleAppRequest { + return AppleAppRequest{ID: &id, Country: country, Ratings: true} +} + +func ByBundleID(appID, country string) AppleAppRequest { + return AppleAppRequest{AppID: &appID, Country: country, Ratings: true} +} diff --git a/internal/api/google/google_scraper.go b/internal/api/google/google_scraper.go index 5692595..644ff62 100644 --- a/internal/api/google/google_scraper.go +++ b/internal/api/google/google_scraper.go @@ -5,108 +5,110 @@ import ( "context" "encoding/json" "fmt" + "io" "net/http" "time" + "github.com/miti99/store-scraper-bot-go/internal/api/google/request" "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"` -} +// BaseURL mirrors Java GooglePlayScraper (api/google/GooglePlayScraper.java). +const BaseURL = "https://store-scraper.vercel.app/google" type GoogleScraper struct { - httpClient *http.Client - appRepo *repository.GoogleAppRepository - logger *zap.Logger + repo *repository.GoogleAppRepository + cfg *config.Config + client *http.Client + logger *zap.Logger } -func NewGoogleScraper(appRepo *repository.GoogleAppRepository, cfg *config.Config) *GoogleScraper { +func NewGoogleScraper(repo *repository.GoogleAppRepository, cfg *config.Config) *GoogleScraper { return &GoogleScraper{ - httpClient: &http.Client{ - Timeout: 30 * time.Second, - }, - appRepo: appRepo, - logger: cfg.Logger, + repo: repo, + cfg: cfg, + client: &http.Client{Timeout: 30 * time.Second}, + logger: cfg.Logger, } } -func (s *GoogleScraper) GetApp(appID, country string) (*model.GoogleAppResponse, error) { - // Check cache first - cachedApp, err := s.appRepo.GetCached(appID) +func (s *GoogleScraper) RawApp(req request.GoogleAppRequest) (string, error) { + body, err := json.Marshal(req) if err != nil { - s.logger.Error("Failed to get cached google app", zap.Error(err), zap.String("appId", appID)) + return "", fmt.Errorf("marshal google request: %w", err) } - if cachedApp != nil { - s.logger.Debug("Returning cached google app", zap.String("appId", appID)) - return &cachedApp.App, nil + httpReq, err := http.NewRequestWithContext(context.Background(), http.MethodPost, BaseURL+"/app", bytes.NewReader(body)) + if err != nil { + return "", fmt.Errorf("build google request: %w", err) } + httpReq.Header.Set("Content-Type", "application/json") - // 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) + resp, err := s.client.Do(httpReq) + if err != nil { + return "", fmt.Errorf("google HTTP error: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("google HTTP status %d", resp.StatusCode) + } + raw, err := io.ReadAll(resp.Body) + if err != nil { + return "", fmt.Errorf("read google body: %w", err) + } + return string(raw), nil +} + +func (s *GoogleScraper) App(req request.GoogleAppRequest) (*model.GoogleAppResponse, error) { + raw, err := s.RawApp(req) if err != nil { return nil, err } + out := &model.GoogleAppResponse{} + if err := json.Unmarshal([]byte(raw), out); err != nil { + return nil, fmt.Errorf("decode google response: %w", err) + } + return out, nil +} - // Save to cache +func (s *GoogleScraper) GetApp(appID, country string) (*model.GoogleAppResponse, error) { + if cached, _ := s.repo.GetCached(appID); cached != nil { + return &cached.App, nil + } + resp, err := s.App(request.New(appID, country)) + if err != nil { + return nil, err + } + s.cache(resp, appID) + return resp, nil +} + +func (s *GoogleScraper) FetchAndCache(req request.GoogleAppRequest) (*model.GoogleAppResponse, error) { + resp, err := s.App(req) + if err != nil { + return nil, err + } + s.cache(resp, req.AppID) + return resp, nil +} + +func (s *GoogleScraper) cache(resp *model.GoogleAppResponse, fallbackID string) { + if resp == nil { + return + } + id := resp.AppID + if id == "" { + id = fallbackID + } + if id == "" { + return + } 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)) + entry := model.NewGoogleApp(id, *resp, time.Now().UnixMilli()) + if err := s.repo.Save(ctx, entry); err != nil { + s.logger.Warn("failed to cache google app", zap.String("appId", id), zap.Error(err)) } - - 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/api/google/request/google_app_request.go b/internal/api/google/request/google_app_request.go new file mode 100644 index 0000000..6bcb32e --- /dev/null +++ b/internal/api/google/request/google_app_request.go @@ -0,0 +1,14 @@ +package request + +// GoogleAppRequest mirrors Java GoogleAppRequest record. Country defaults to "vn". +type GoogleAppRequest struct { + AppID string `json:"appId"` + Country string `json:"country"` +} + +func New(appID, country string) GoogleAppRequest { + if country == "" { + country = "vn" + } + return GoogleAppRequest{AppID: appID, Country: country} +} diff --git a/internal/bot/bot.go b/internal/bot/bot.go index c74b531..198f635 100644 --- a/internal/bot/bot.go +++ b/internal/bot/bot.go @@ -12,15 +12,14 @@ import ( "go.uber.org/zap" ) +// parseMode mirrors Java StoreScrapeBotTelegramClient: HTML for all messages. +const parseMode = "HTML" + 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 + api *tgbotapi.BotAPI + cfg *config.Config + commands map[string]command.Command + logger *zap.Logger } func NewBot( @@ -30,107 +29,99 @@ func NewBot( appleScraper *apple.AppleScraper, googleScraper *google.GoogleScraper, ) (*Bot, error) { - bot, err := tgbotapi.NewBotAPI(cfg.TelegramBotToken) + api, err := tgbotapi.NewBotAPI(cfg.TelegramBotToken) if err != nil { return nil, fmt.Errorf("failed to create telegram bot: %w", err) } + api.Debug = cfg.Env == config.Development + cfg.Logger.Info("Authorized on account", zap.String("username", api.Self.UserName)) - bot.Debug = cfg.Env == config.Development + b := &Bot{api: api, cfg: cfg, commands: map[string]command.Command{}, logger: cfg.Logger} - cfg.Logger.Info("Authorized on account", zap.String("username", bot.Self.UserName)) + // Java command identifiers (StoreScrapeBot constructor) — keep these strings + // matching exactly so existing users' muscle memory still works. + b.commands["info"] = command.NewInfoCommand(cfg) + b.commands["addgroup"] = command.NewAddGroupCommand(cfg, adminRepo, groupRepo) + b.commands["delgroup"] = command.NewDeleteGroupCommand(cfg, adminRepo, groupRepo) + b.commands["listgroup"] = command.NewListGroupCommand(cfg, adminRepo) + b.commands["addapple"] = command.NewAddAppleAppCommand(cfg, adminRepo, groupRepo, appleScraper) + b.commands["delapple"] = command.NewDeleteAppleAppCommand(cfg, adminRepo, groupRepo) + b.commands["addgoogle"] = command.NewAddGoogleAppCommand(cfg, adminRepo, groupRepo, googleScraper) + b.commands["delgoogle"] = command.NewDeleteGoogleAppCommand(cfg, adminRepo, groupRepo) + b.commands["listapp"] = command.NewListAppCommand(cfg, adminRepo, groupRepo) + b.commands["checkapp"] = command.NewCheckAppCommand(cfg, adminRepo, groupRepo, appleScraper, googleScraper) + b.commands["checkappscore"] = command.NewCheckAppScoresCommand(cfg, adminRepo, groupRepo, appleScraper, googleScraper) + b.commands["rawappleapp"] = command.NewRawAppleAppCommand(cfg, appleScraper) + b.commands["rawgoogleapp"] = command.NewRawGoogleAppCommand(cfg, googleScraper) - 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 { + if update.Message == nil || !update.Message.IsCommand() { 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)) + defer func() { + if r := recover(); r != nil { + b.logger.Error("panic in command", zap.Any("panic", r)) + _ = b.SendMessage(message.Chat.ID, "Internal server error") + } + }() + name := message.Command() + cmd, ok := b.commands[name] + if !ok { + b.logger.Debug("Unknown command", zap.String("command", name)) return } - b.logger.Info("Executing command", - zap.String("command", commandName), + zap.String("command", name), 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)) - } - } + cmd.Execute(message, b) } -func (b *Bot) SendMessage(chatID int64, text string) error { - msg := tgbotapi.NewMessage(chatID, text) - msg.ParseMode = "Markdown" +// SendMessage sends an HTML-parsed message (Java parity). +func (b *Bot) SendMessage(chatID int64, html string) error { + msg := tgbotapi.NewMessage(chatID, html) + msg.ParseMode = parseMode msg.DisableWebPagePreview = true - msg.DisableNotification = false - _, err := b.api.Send(msg) + if err != nil { + b.logger.Warn("send message failed", zap.Int64("chatId", chatID), zap.Error(err)) + } return err } -func (b *Bot) SendMessageSilent(chatID int64, text string) error { - msg := tgbotapi.NewMessage(chatID, text) - msg.ParseMode = "Markdown" +// SendMessageSilent sends an HTML message with notifications muted (weekend behavior). +func (b *Bot) SendMessageSilent(chatID int64, html string) error { + msg := tgbotapi.NewMessage(chatID, html) + msg.ParseMode = parseMode msg.DisableWebPagePreview = true msg.DisableNotification = true - _, err := b.api.Send(msg) + if err != nil { + b.logger.Warn("send silent message failed", zap.Int64("chatId", chatID), zap.Error(err)) + } + return err +} + +// SendDocument sends body as a file attachment with the given filename +// (used by /rawappleapp and /rawgoogleapp). +func (b *Bot) SendDocument(chatID int64, filename, body string) error { + file := tgbotapi.FileBytes{Name: filename, Bytes: []byte(body)} + doc := tgbotapi.NewDocument(chatID, file) + _, err := b.api.Send(doc) + if err != nil { + b.logger.Warn("send document failed", zap.Int64("chatId", chatID), zap.Error(err)) + } return err } diff --git a/internal/bot/command/add_apple_app.go b/internal/bot/command/add_apple_app.go index 2f80900..110011d 100644 --- a/internal/bot/command/add_apple_app.go +++ b/internal/bot/command/add_apple_app.go @@ -2,69 +2,63 @@ package command import ( "fmt" - "strings" + "strconv" 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/apple/request" "github.com/miti99/store-scraper-bot-go/internal/config" "github.com/miti99/store-scraper-bot-go/internal/repository" ) +// /addapple [country=vn] — Java AddAppleAppCommand. type AddAppleAppCommand struct { - BaseCommand - adminRepo *repository.AdminRepository - groupRepo *repository.GroupRepository - appleScraper *apple.AppleScraper + cfg *config.Config + adminRepo *repository.AdminRepository + groupRepo *repository.GroupRepository + scraper *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 NewAddAppleAppCommand(cfg *config.Config, adminRepo *repository.AdminRepository, groupRepo *repository.GroupRepository, scraper *apple.AppleScraper) *AddAppleAppCommand { + return &AddAppleAppCommand{cfg: cfg, adminRepo: adminRepo, groupRepo: groupRepo, scraper: scraper} } -func (c *AddAppleAppCommand) Execute(message *tgbotapi.Message) string { - if !c.requireAdmin(message) { - return "You are not authorized to use this command." +func (c *AddAppleAppCommand) Execute(msg *tgbotapi.Message, sender Sender) { + if !authorizeGroup(msg.Chat.ID, c.adminRepo, sender) { + return } - - groupID := message.Chat.ID - hasGroup, err := c.adminRepo.HasGroup(groupID) - if err != nil { - return fmt.Sprintf("Failed to check group: %v", err) + args := splitArgs(msg.CommandArguments()) + if len(args) < 1 || len(args) > 2 { + _ = sender.SendMessage(msg.Chat.ID, "Invalid arguments") + return } - 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 { + if len(args) == 2 { country = args[1] } - // Verify app exists - app, err := c.appleScraper.GetApp(appID, country) + // Java: try parsing arg[0] as Long (trackId); else treat as bundleId. + var req request.AppleAppRequest + if trackID, err := strconv.ParseInt(args[0], 10, 64); err == nil { + req = request.ByTrackID(trackID, country) + } else { + req = request.ByBundleID(args[0], country) + } + + resp, err := c.scraper.FetchAndCache(req) + if err != nil || resp == nil || resp.AppID == "" { + _ = sender.SendMessage(msg.Chat.ID, "Error when request app info") + return + } + + added, err := c.groupRepo.AddAppleApp(msg.Chat.ID, resp.AppID, country) if err != nil { - return fmt.Sprintf("Failed to fetch app from store: %v", err) + _ = sender.SendMessage(msg.Chat.ID, "Internal server error") + return } - - if err := c.groupRepo.AddAppleApp(groupID, appID, country); err != nil { - return fmt.Sprintf("Failed to add app: %v", err) + if !added { + _ = sender.SendMessage(msg.Chat.ID, fmt.Sprintf("Apple app %s is already added", resp.AppID)) + return } - - return fmt.Sprintf("Apple app added successfully:\n*%s*\nApp ID: %s\nCountry: %s\nScore: %.1f", app.Title, appID, country, app.Score) + _ = sender.SendMessage(msg.Chat.ID, fmt.Sprintf("Apple app %s, country %s added successfully", resp.AppID, country)) } diff --git a/internal/bot/command/add_google_app.go b/internal/bot/command/add_google_app.go index e88e998..06ec4f6 100644 --- a/internal/bot/command/add_google_app.go +++ b/internal/bot/command/add_google_app.go @@ -2,69 +2,53 @@ 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/api/google/request" "github.com/miti99/store-scraper-bot-go/internal/config" "github.com/miti99/store-scraper-bot-go/internal/repository" ) +// /addgoogle [country=vn] — Java AddGoogleAppCommand. type AddGoogleAppCommand struct { - BaseCommand - adminRepo *repository.AdminRepository - groupRepo *repository.GroupRepository - googleScraper *google.GoogleScraper + cfg *config.Config + adminRepo *repository.AdminRepository + groupRepo *repository.GroupRepository + scraper *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 NewAddGoogleAppCommand(cfg *config.Config, adminRepo *repository.AdminRepository, groupRepo *repository.GroupRepository, scraper *google.GoogleScraper) *AddGoogleAppCommand { + return &AddGoogleAppCommand{cfg: cfg, adminRepo: adminRepo, groupRepo: groupRepo, scraper: scraper} } -func (c *AddGoogleAppCommand) Execute(message *tgbotapi.Message) string { - if !c.requireAdmin(message) { - return "You are not authorized to use this command." +func (c *AddGoogleAppCommand) Execute(msg *tgbotapi.Message, sender Sender) { + if !authorizeGroup(msg.Chat.ID, c.adminRepo, sender) { + return } - - groupID := message.Chat.ID - hasGroup, err := c.adminRepo.HasGroup(groupID) - if err != nil { - return fmt.Sprintf("Failed to check group: %v", err) + args := splitArgs(msg.CommandArguments()) + if len(args) < 1 || len(args) > 2 { + _ = sender.SendMessage(msg.Chat.ID, "Invalid arguments") + return } - 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 { + if len(args) == 2 { country = args[1] } - - // Verify app exists - app, err := c.googleScraper.GetApp(appID, country) + resp, err := c.scraper.FetchAndCache(request.New(appID, country)) + if err != nil || resp == nil { + _ = sender.SendMessage(msg.Chat.ID, "Error when request app info") + return + } + added, err := c.groupRepo.AddGoogleApp(msg.Chat.ID, appID, country) if err != nil { - return fmt.Sprintf("Failed to fetch app from store: %v", err) + _ = sender.SendMessage(msg.Chat.ID, "Internal server error") + return } - - if err := c.groupRepo.AddGoogleApp(groupID, appID, country); err != nil { - return fmt.Sprintf("Failed to add app: %v", err) + if !added { + _ = sender.SendMessage(msg.Chat.ID, fmt.Sprintf("Google app %s is already added", appID)) + return } - - return fmt.Sprintf("Google app added successfully:\n*%s*\nApp ID: %s\nCountry: %s\nScore: %.1f", app.Title, appID, country, app.Score) + _ = sender.SendMessage(msg.Chat.ID, fmt.Sprintf("Google app %s, country %s added successfully", appID, country)) } diff --git a/internal/bot/command/add_group.go b/internal/bot/command/add_group.go index e9b50a5..cb053dd 100644 --- a/internal/bot/command/add_group.go +++ b/internal/bot/command/add_group.go @@ -1,36 +1,55 @@ package command import ( - "fmt" + "context" + "strconv" + "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" ) +// /addgroup [groupId] — Java AddGroupCommand. Admin-only. type AddGroupCommand struct { - BaseCommand + cfg *config.Config 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, - } + return &AddGroupCommand{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." +func (c *AddGroupCommand) Execute(msg *tgbotapi.Message, sender Sender) { + if !requireAdminUser(msg.From.ID, msg.Chat.ID, c.cfg, sender) { + return } - - groupID := message.Chat.ID - if err := c.adminRepo.AddGroup(groupID); err != nil { - return fmt.Sprintf("Failed to add group: %v", err) + args := splitArgs(msg.CommandArguments()) + if len(args) > 1 { + _ = sender.SendMessage(msg.Chat.ID, "Invalid arguments") + return } - - return fmt.Sprintf("Group %d has been added successfully.", groupID) + groupID := msg.Chat.ID + if len(args) == 1 { + parsed, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + _ = sender.SendMessage(msg.Chat.ID, "Invalid arguments") + return + } + groupID = parsed + } + added, err := c.adminRepo.AddGroup(groupID) + if err != nil { + _ = sender.SendMessage(msg.Chat.ID, "Internal server error") + return + } + if !added { + _ = sender.SendMessage(msg.Chat.ID, "Group is already added") + return + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = c.groupRepo.Init(ctx, groupID) + _ = sender.SendMessage(msg.Chat.ID, "Group added successfully") } diff --git a/internal/bot/command/args.go b/internal/bot/command/args.go new file mode 100644 index 0000000..217ff2e --- /dev/null +++ b/internal/bot/command/args.go @@ -0,0 +1,13 @@ +package command + +import "strings" + +// splitArgs mirrors Java BotCommand argument parsing: split on whitespace, +// drop empty tokens. +func splitArgs(s string) []string { + if s == "" { + return nil + } + parts := strings.Fields(s) + return parts +} diff --git a/internal/bot/command/check_app.go b/internal/bot/command/check_app.go index db41d5c..33913b6 100644 --- a/internal/bot/command/check_app.go +++ b/internal/bot/command/check_app.go @@ -13,153 +13,108 @@ import ( "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" ) +// /checkapp — Java CheckAppCommand. Reports update status per app, per store. type CheckAppCommand struct { - BaseCommand + cfg *config.Config 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 NewCheckAppCommand(cfg *config.Config, adminRepo *repository.AdminRepository, groupRepo *repository.GroupRepository, a *apple.AppleScraper, g *google.GoogleScraper) *CheckAppCommand { + return &CheckAppCommand{cfg: cfg, adminRepo: adminRepo, groupRepo: groupRepo, appleScraper: a, googleScraper: g} } -func (c *CheckAppCommand) Execute(message *tgbotapi.Message) string { - if !c.requireAdmin(message) { - return "You are not authorized to use this command." +func (c *CheckAppCommand) Execute(msg *tgbotapi.Message, sender Sender) { + if !authorizeGroup(msg.Chat.ID, c.adminRepo, sender) { + return + } + if len(splitArgs(msg.CommandArguments())) != 0 { + _ = sender.SendMessage(msg.Chat.ID, "Invalid arguments") + return } - 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) + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() - - group, err := c.groupRepo.Get(ctx, groupID) + group, err := c.groupRepo.Get(ctx, msg.Chat.ID) if err != nil { - return fmt.Sprintf("Failed to get group: %v", err) + _ = sender.SendMessage(msg.Chat.ID, "Internal server error") + return } + now := time.Now() + threshold := c.cfg.NumDaysWarningNotUpdated - 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) + headers := []string{"AppId", "Updated", "Days", "OK"} + appleRows := c.appleRows(group.AppleApps, now, threshold) + googleRows := c.googleRows(group.GoogleApps, now, threshold) 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) + sb.WriteString("Apple Apps\n") + if len(appleRows) == 0 { + sb.WriteString("(none)\n") + } else { + sb.WriteString(fmt.Sprintf("
%s
\n", util.BuildTable(headers, appleRows))) + } + sb.WriteString("\nGoogle Apps\n") + if len(googleRows) == 0 { + sb.WriteString("(none)\n") + } else { + sb.WriteString(fmt.Sprintf("
%s
\n", util.BuildTable(headers, googleRows))) + } + _ = sender.SendMessage(msg.Chat.ID, sb.String()) +} - return sb.String() +func (c *CheckAppCommand) appleRows(apps []model.AppInfo, now time.Time, threshold int) [][]string { + rows := make([][]string, 0, len(apps)) + for _, a := range apps { + resp, err := c.appleScraper.GetApp(a.AppID, a.Country) + if err != nil || resp == nil { + rows = append(rows, []string{a.AppID, "?", "?", okMark(false)}) + continue + } + updated, days, ok := evalAppleUpdated(resp.Updated, now, threshold) + rows = append(rows, []string{a.AppID, updated, fmt.Sprintf("%d", days), okMark(ok)}) + } + return rows +} + +func (c *CheckAppCommand) googleRows(apps []model.AppInfo, now time.Time, threshold int) [][]string { + rows := make([][]string, 0, len(apps)) + for _, a := range apps { + resp, err := c.googleScraper.GetApp(a.AppID, a.Country) + if err != nil || resp == nil { + rows = append(rows, []string{a.AppID, "?", "?", okMark(false)}) + continue + } + updated, days, ok := evalGoogleUpdated(resp.Updated, now, threshold) + rows = append(rows, []string{a.AppID, updated, fmt.Sprintf("%d", days), okMark(ok)}) + } + return rows +} + +// evalAppleUpdated parses Apple's ISO 8601 timestamp and returns (yyyy-MM-dd, +// days since update, OK). +func evalAppleUpdated(updated string, now time.Time, threshold int) (string, int, bool) { + t, err := time.Parse(time.RFC3339, updated) + if err != nil { + return updated, 0, false + } + days := int(now.Sub(t).Hours() / 24) + return t.Format("2006-01-02"), days, days <= threshold +} + +func evalGoogleUpdated(millis int64, now time.Time, threshold int) (string, int, bool) { + t := time.UnixMilli(millis) + days := int(now.Sub(t).Hours() / 24) + return t.Format("2006-01-02"), days, days <= threshold +} + +func okMark(ok bool) string { + if ok { + return "✅" + } + return "❌" } diff --git a/internal/bot/command/check_app_scores.go b/internal/bot/command/check_app_scores.go index 7bf1e60..56d68b3 100644 --- a/internal/bot/command/check_app_scores.go +++ b/internal/bot/command/check_app_scores.go @@ -3,6 +3,7 @@ package command import ( "context" "fmt" + "math" "strings" "time" @@ -10,114 +11,90 @@ import ( "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" ) +// /checkappscore — Java CheckAppScoreCommand. Reports score + ratings count. +// Score is rounded to 1 decimal (Java Precision.round(score, 1) parity). type CheckAppScoresCommand struct { - BaseCommand + cfg *config.Config 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 NewCheckAppScoresCommand(cfg *config.Config, adminRepo *repository.AdminRepository, groupRepo *repository.GroupRepository, a *apple.AppleScraper, g *google.GoogleScraper) *CheckAppScoresCommand { + return &CheckAppScoresCommand{cfg: cfg, adminRepo: adminRepo, groupRepo: groupRepo, appleScraper: a, googleScraper: g} } -func (c *CheckAppScoresCommand) Execute(message *tgbotapi.Message) string { - if !c.requireAdmin(message) { - return "You are not authorized to use this command." +func (c *CheckAppScoresCommand) Execute(msg *tgbotapi.Message, sender Sender) { + if !authorizeGroup(msg.Chat.ID, c.adminRepo, sender) { + return + } + if len(splitArgs(msg.CommandArguments())) != 0 { + _ = sender.SendMessage(msg.Chat.ID, "Invalid arguments") + return } - 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) + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) defer cancel() - - group, err := c.groupRepo.Get(ctx, groupID) + group, err := c.groupRepo.Get(ctx, msg.Chat.ID) if err != nil { - return fmt.Sprintf("Failed to get group: %v", err) + _ = sender.SendMessage(msg.Chat.ID, "Internal server error") + return } - 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) + headers := []string{"AppId", "Score", "Ratings"} + appleRows := c.appleScoreRows(group.AppleApps) + googleRows := c.googleScoreRows(group.GoogleApps) var sb strings.Builder - sb.WriteString(fmt.Sprintf("*App Scores Report*\nGroup: %d\n\n", groupID)) - sb.WriteString(table) + sb.WriteString("Apple Apps\n") + if len(appleRows) == 0 { + sb.WriteString("(none)\n") + } else { + sb.WriteString(fmt.Sprintf("
%s
\n", util.BuildTable(headers, appleRows))) + } + sb.WriteString("\nGoogle Apps\n") + if len(googleRows) == 0 { + sb.WriteString("(none)\n") + } else { + sb.WriteString(fmt.Sprintf("
%s
\n", util.BuildTable(headers, googleRows))) + } + _ = sender.SendMessage(msg.Chat.ID, sb.String()) +} - return sb.String() +func (c *CheckAppScoresCommand) appleScoreRows(apps []model.AppInfo) [][]string { + rows := make([][]string, 0, len(apps)) + for _, a := range apps { + resp, err := c.appleScraper.GetApp(a.AppID, a.Country) + if err != nil || resp == nil { + rows = append(rows, []string{a.AppID, "?", "?"}) + continue + } + rows = append(rows, []string{a.AppID, formatScore(resp.Score), fmt.Sprintf("%d", resp.Ratings)}) + } + return rows +} + +func (c *CheckAppScoresCommand) googleScoreRows(apps []model.AppInfo) [][]string { + rows := make([][]string, 0, len(apps)) + for _, a := range apps { + resp, err := c.googleScraper.GetApp(a.AppID, a.Country) + if err != nil || resp == nil { + rows = append(rows, []string{a.AppID, "?", "?"}) + continue + } + rows = append(rows, []string{a.AppID, formatScore(resp.Score), fmt.Sprintf("%d", resp.Ratings)}) + } + return rows +} + +// formatScore rounds to 1 decimal place (Java Precision.round(score, 1)). +func formatScore(score float64) string { + rounded := math.Round(score*10) / 10 + return fmt.Sprintf("%.1f", rounded) } diff --git a/internal/bot/command/command.go b/internal/bot/command/command.go index d85d2f0..c07bb8b 100644 --- a/internal/bot/command/command.go +++ b/internal/bot/command/command.go @@ -3,22 +3,36 @@ package command import ( 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" ) +// Sender is what bot.Bot exposes to commands. HTML parse mode (Java parity). +type Sender interface { + SendMessage(chatID int64, html string) error + SendMessageSilent(chatID int64, html string) error + SendDocument(chatID int64, filename, body string) error +} + +// Command is the unit registered on the bot dispatcher. type Command interface { - Execute(message *tgbotapi.Message) string + Execute(msg *tgbotapi.Message, sender Sender) } -type BaseCommand struct { - cfg *config.Config +// authorizeGroup verifies the chat is in the admin's authorized group list. +// Mirrors Java's per-command "Group is not allowed to use bot" gate. +func authorizeGroup(chatID int64, adminRepo *repository.AdminRepository, sender Sender) bool { + ok, err := adminRepo.HasGroup(chatID) + if err != nil || !ok { + _ = sender.SendMessage(chatID, "Group is not allowed to use bot") + return false + } + return true } -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) { +// requireAdminUser checks the user is in Environment.ADMIN_IDS. +func requireAdminUser(userID, chatID int64, cfg *config.Config, sender Sender) bool { + if !cfg.IsAdmin(userID) { + _ = sender.SendMessage(chatID, "You are not authorized to use this command") return false } return true diff --git a/internal/bot/command/delete_apple_app.go b/internal/bot/command/delete_apple_app.go index f3d3222..903030c 100644 --- a/internal/bot/command/delete_apple_app.go +++ b/internal/bot/command/delete_apple_app.go @@ -1,52 +1,39 @@ 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" ) +// /delapple — Java DeleteAppleAppCommand. type DeleteAppleAppCommand struct { - BaseCommand + cfg *config.Config 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, - } + return &DeleteAppleAppCommand{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." +func (c *DeleteAppleAppCommand) Execute(msg *tgbotapi.Message, sender Sender) { + if !authorizeGroup(msg.Chat.ID, c.adminRepo, sender) { + return } - - groupID := message.Chat.ID - hasGroup, err := c.adminRepo.HasGroup(groupID) + args := splitArgs(msg.CommandArguments()) + if len(args) != 1 { + _ = sender.SendMessage(msg.Chat.ID, "Invalid arguments") + return + } + removed, err := c.groupRepo.RemoveAppleApp(msg.Chat.ID, args[0]) if err != nil { - return fmt.Sprintf("Failed to check group: %v", err) + _ = sender.SendMessage(msg.Chat.ID, "Internal server error") + return } - if !hasGroup { - return "This group is not registered." + if !removed { + _ = sender.SendMessage(msg.Chat.ID, "Apple app is not added") + return } - - 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) + _ = sender.SendMessage(msg.Chat.ID, "Apple app deleted successfully") } diff --git a/internal/bot/command/delete_google_app.go b/internal/bot/command/delete_google_app.go index 0f06ffa..01a7a7e 100644 --- a/internal/bot/command/delete_google_app.go +++ b/internal/bot/command/delete_google_app.go @@ -1,52 +1,39 @@ 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" ) +// /delgoogle — Java DeleteGoogleAppCommand. type DeleteGoogleAppCommand struct { - BaseCommand + cfg *config.Config 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, - } + return &DeleteGoogleAppCommand{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." +func (c *DeleteGoogleAppCommand) Execute(msg *tgbotapi.Message, sender Sender) { + if !authorizeGroup(msg.Chat.ID, c.adminRepo, sender) { + return } - - groupID := message.Chat.ID - hasGroup, err := c.adminRepo.HasGroup(groupID) + args := splitArgs(msg.CommandArguments()) + if len(args) != 1 { + _ = sender.SendMessage(msg.Chat.ID, "Invalid arguments") + return + } + removed, err := c.groupRepo.RemoveGoogleApp(msg.Chat.ID, args[0]) if err != nil { - return fmt.Sprintf("Failed to check group: %v", err) + _ = sender.SendMessage(msg.Chat.ID, "Internal server error") + return } - if !hasGroup { - return "This group is not registered." + if !removed { + _ = sender.SendMessage(msg.Chat.ID, "Google app is not added") + return } - - 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) + _ = sender.SendMessage(msg.Chat.ID, "Google app deleted successfully") } diff --git a/internal/bot/command/delete_group.go b/internal/bot/command/delete_group.go index d849cb6..e6a906f 100644 --- a/internal/bot/command/delete_group.go +++ b/internal/bot/command/delete_group.go @@ -1,46 +1,50 @@ package command import ( - "context" - "fmt" - "time" + "strconv" 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" ) +// /delgroup [groupId] — Java DeleteGroupCommand. Admin-only. type DeleteGroupCommand struct { - BaseCommand + cfg *config.Config 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, - } + return &DeleteGroupCommand{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." +func (c *DeleteGroupCommand) Execute(msg *tgbotapi.Message, sender Sender) { + if !requireAdminUser(msg.From.ID, msg.Chat.ID, c.cfg, sender) { + return } - - groupID := message.Chat.ID - - if err := c.adminRepo.RemoveGroup(groupID); err != nil { - return fmt.Sprintf("Failed to remove group: %v", err) + args := splitArgs(msg.CommandArguments()) + if len(args) > 1 { + _ = sender.SendMessage(msg.Chat.ID, "Invalid arguments") + return } - - 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) + groupID := msg.Chat.ID + if len(args) == 1 { + parsed, err := strconv.ParseInt(args[0], 10, 64) + if err != nil { + _ = sender.SendMessage(msg.Chat.ID, "Invalid arguments") + return + } + groupID = parsed } - - return fmt.Sprintf("Group %d has been deleted successfully.", groupID) + removed, err := c.adminRepo.RemoveGroup(groupID) + if err != nil { + _ = sender.SendMessage(msg.Chat.ID, "Internal server error") + return + } + if !removed { + _ = sender.SendMessage(msg.Chat.ID, "Group is not added") + return + } + _ = sender.SendMessage(msg.Chat.ID, "Group deleted successfully") } diff --git a/internal/bot/command/info.go b/internal/bot/command/info.go index 23ab0e8..641d4db 100644 --- a/internal/bot/command/info.go +++ b/internal/bot/command/info.go @@ -2,48 +2,21 @@ 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 -} +// /info — Java InfoCommand. Reports the chat (group) ID. +type InfoCommand struct{ cfg *config.Config } -func NewInfoCommand(cfg *config.Config) *InfoCommand { - return &InfoCommand{ - BaseCommand: BaseCommand{cfg: cfg}, +func NewInfoCommand(cfg *config.Config) *InfoCommand { return &InfoCommand{cfg: cfg} } + +func (c *InfoCommand) Execute(msg *tgbotapi.Message, sender Sender) { + args := splitArgs(msg.CommandArguments()) + if len(args) != 0 { + _ = sender.SendMessage(msg.Chat.ID, "Invalid arguments") + return } -} - -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, - ) + _ = sender.SendMessage(msg.Chat.ID, fmt.Sprintf("Id của nhóm là %d\n", msg.Chat.ID)) } diff --git a/internal/bot/command/list_app.go b/internal/bot/command/list_app.go index 31b93be..4dd93df 100644 --- a/internal/bot/command/list_app.go +++ b/internal/bot/command/list_app.go @@ -3,71 +3,59 @@ package command import ( "context" "fmt" + "strconv" "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/model" "github.com/miti99/store-scraper-bot-go/internal/repository" + "github.com/miti99/store-scraper-bot-go/internal/util" ) +// /listapp — Java ListAppCommand. Two tables (Apple / Google) of tracked apps. type ListAppCommand struct { - BaseCommand + cfg *config.Config 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, - } + return &ListAppCommand{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." +func (c *ListAppCommand) Execute(msg *tgbotapi.Message, sender Sender) { + if !authorizeGroup(msg.Chat.ID, c.adminRepo, sender) { + return } - - groupID := message.Chat.ID - hasGroup, err := c.adminRepo.HasGroup(groupID) - if err != nil { - return fmt.Sprintf("Failed to check group: %v", err) + if len(splitArgs(msg.CommandArguments())) != 0 { + _ = sender.SendMessage(msg.Chat.ID, "Invalid arguments") + return } - 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) + group, err := c.groupRepo.Get(ctx, msg.Chat.ID) if err != nil { - return fmt.Sprintf("Failed to get group: %v", err) + _ = sender.SendMessage(msg.Chat.ID, "Internal server error") + return } var sb strings.Builder - sb.WriteString("*Apps in this group:*\n\n") + sb.WriteString("Apple Apps\n") + sb.WriteString(formatAppTable(group.AppleApps)) + sb.WriteString("\nGoogle Apps\n") + sb.WriteString(formatAppTable(group.GoogleApps)) + _ = sender.SendMessage(msg.Chat.ID, sb.String()) +} - 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") +func formatAppTable(apps []model.AppInfo) string { + if len(apps) == 0 { + return "(none)\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." + rows := make([][]string, 0, len(apps)) + for i, a := range apps { + rows = append(rows, []string{strconv.Itoa(i + 1), a.AppID, a.Country}) } - - return sb.String() + return fmt.Sprintf("
%s
\n", util.BuildTable([]string{"#", "AppId", "Country"}, rows)) } diff --git a/internal/bot/command/list_group.go b/internal/bot/command/list_group.go index 58bfdee..e80fcc8 100644 --- a/internal/bot/command/list_group.go +++ b/internal/bot/command/list_group.go @@ -9,37 +9,37 @@ import ( "github.com/miti99/store-scraper-bot-go/internal/repository" ) +// /listgroup — Java ListGroupCommand. Admin-only. Lists authorized groups. type ListGroupCommand struct { - BaseCommand + cfg *config.Config adminRepo *repository.AdminRepository } func NewListGroupCommand(cfg *config.Config, adminRepo *repository.AdminRepository) *ListGroupCommand { - return &ListGroupCommand{ - BaseCommand: BaseCommand{cfg: cfg}, - adminRepo: adminRepo, - } + return &ListGroupCommand{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." +func (c *ListGroupCommand) Execute(msg *tgbotapi.Message, sender Sender) { + if !requireAdminUser(msg.From.ID, msg.Chat.ID, c.cfg, sender) { + return + } + if len(splitArgs(msg.CommandArguments())) != 0 { + _ = sender.SendMessage(msg.Chat.ID, "Invalid arguments") + return } - groups, err := c.adminRepo.GetAllGroups() if err != nil { - return fmt.Sprintf("Failed to get groups: %v", err) + _ = sender.SendMessage(msg.Chat.ID, "Internal server error") + return } - if len(groups) == 0 { - return "No groups found." + _ = sender.SendMessage(msg.Chat.ID, "No groups found") + return } - 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)) + sb.WriteString(fmt.Sprintf("Authorized groups (%d):\n", len(groups))) + for i, gid := range groups { + sb.WriteString(fmt.Sprintf("%d. %d\n", i+1, gid)) } - - return sb.String() + _ = sender.SendMessage(msg.Chat.ID, sb.String()) } diff --git a/internal/bot/command/raw_apple_app.go b/internal/bot/command/raw_apple_app.go index dcdd2f4..b8b1b66 100644 --- a/internal/bot/command/raw_apple_app.go +++ b/internal/bot/command/raw_apple_app.go @@ -1,58 +1,48 @@ package command import ( - "encoding/json" "fmt" - "strings" + "strconv" 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/apple/request" "github.com/miti99/store-scraper-bot-go/internal/config" ) +// /rawappleapp [country=vn] — Java RawAppleAppCommand. +// Sends the raw upstream JSON as a Telegram document attachment. type RawAppleAppCommand struct { - BaseCommand - appleScraper *apple.AppleScraper + cfg *config.Config + scraper *apple.AppleScraper } -func NewRawAppleAppCommand(cfg *config.Config, appleScraper *apple.AppleScraper) *RawAppleAppCommand { - return &RawAppleAppCommand{ - BaseCommand: BaseCommand{cfg: cfg}, - appleScraper: appleScraper, - } +func NewRawAppleAppCommand(cfg *config.Config, scraper *apple.AppleScraper) *RawAppleAppCommand { + return &RawAppleAppCommand{cfg: cfg, scraper: scraper} } -func (c *RawAppleAppCommand) Execute(message *tgbotapi.Message) string { - if !c.requireAdmin(message) { - return "You are not authorized to use this command." +func (c *RawAppleAppCommand) Execute(msg *tgbotapi.Message, sender Sender) { + args := splitArgs(msg.CommandArguments()) + if len(args) < 1 || len(args) > 2 { + _ = sender.SendMessage(msg.Chat.ID, "Invalid arguments") + return } - - 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 { + if len(args) == 2 { country = args[1] } - app, err := c.appleScraper.GetApp(appID, country) - if err != nil { - return fmt.Sprintf("Failed to fetch app: %v", err) + var req request.AppleAppRequest + if trackID, err := strconv.ParseInt(args[0], 10, 64); err == nil { + req = request.ByTrackID(trackID, country) + } else { + req = request.ByBundleID(args[0], country) } - jsonData, err := json.MarshalIndent(app, "", " ") - if err != nil { - return fmt.Sprintf("Failed to marshal JSON: %v", err) + raw, err := c.scraper.RawApp(req) + if err != nil || raw == "" { + _ = sender.SendMessage(msg.Chat.ID, "Error when request app info") + return } - - // 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) + _ = sender.SendDocument(msg.Chat.ID, fmt.Sprintf("%s.json", args[0]), raw) } diff --git a/internal/bot/command/raw_google_app.go b/internal/bot/command/raw_google_app.go index f4f37db..66acb8d 100644 --- a/internal/bot/command/raw_google_app.go +++ b/internal/bot/command/raw_google_app.go @@ -1,58 +1,40 @@ 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/api/google/request" "github.com/miti99/store-scraper-bot-go/internal/config" ) +// /rawgoogleapp [country=vn] — Java RawGoogleAppCommand. +// Sends raw upstream JSON as a Telegram document. type RawGoogleAppCommand struct { - BaseCommand - googleScraper *google.GoogleScraper + cfg *config.Config + scraper *google.GoogleScraper } -func NewRawGoogleAppCommand(cfg *config.Config, googleScraper *google.GoogleScraper) *RawGoogleAppCommand { - return &RawGoogleAppCommand{ - BaseCommand: BaseCommand{cfg: cfg}, - googleScraper: googleScraper, - } +func NewRawGoogleAppCommand(cfg *config.Config, scraper *google.GoogleScraper) *RawGoogleAppCommand { + return &RawGoogleAppCommand{cfg: cfg, scraper: scraper} } -func (c *RawGoogleAppCommand) Execute(message *tgbotapi.Message) string { - if !c.requireAdmin(message) { - return "You are not authorized to use this command." +func (c *RawGoogleAppCommand) Execute(msg *tgbotapi.Message, sender Sender) { + args := splitArgs(msg.CommandArguments()) + if len(args) < 1 || len(args) > 2 { + _ = sender.SendMessage(msg.Chat.ID, "Invalid arguments") + return } - - 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 { + if len(args) == 2 { country = args[1] } - - app, err := c.googleScraper.GetApp(appID, country) - if err != nil { - return fmt.Sprintf("Failed to fetch app: %v", err) + raw, err := c.scraper.RawApp(request.New(appID, country)) + if err != nil || raw == "" { + _ = sender.SendMessage(msg.Chat.ID, "Error when request app info") + return } - - 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) + _ = sender.SendDocument(msg.Chat.ID, fmt.Sprintf("%s.json", appID), raw) } diff --git a/internal/config/config.go b/internal/config/config.go index 3a4b4d7..ee3f25d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -7,6 +7,7 @@ import ( "strings" "time" + "go.mongodb.org/mongo-driver/mongo/options" "go.uber.org/zap" ) @@ -17,15 +18,17 @@ const ( Production Environment = "PRODUCTION" ) +const DefaultDatabaseName = "store-scraper-bot" + type Config struct { // Telegram TelegramBotToken string TelegramBotUsername string // MongoDB - MongoURI string - MongoDatabase string - MongoTimeout time.Duration + MongoURI string + MongoDatabase string + MongoTimeout time.Duration // Application Env Environment @@ -34,10 +37,10 @@ type Config struct { SourceCommit string // Constants - AppCacheSeconds int - NumDaysWarningNotUpdated int - ScheduleCheckAppTime string - VietnamLocation *time.Location + AppCacheSeconds int + NumDaysWarningNotUpdated int + ScheduleCheckAppTime string + VietnamLocation *time.Location // Logger Logger *zap.Logger @@ -48,7 +51,6 @@ 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") @@ -58,12 +60,14 @@ func Load() (*Config, error) { 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") + // Java parity: prefer MONGODB_CONNECTION_STRING. Fall back to MONGO_URI. + cfg.MongoURI = getEnv("MONGODB_CONNECTION_STRING", getEnv("MONGO_URI", "mongodb://localhost:27017")) + cfg.MongoDatabase = getEnv("MONGO_DATABASE", "") + if cfg.MongoDatabase == "" { + cfg.MongoDatabase = databaseFromURI(cfg.MongoURI) + } cfg.MongoTimeout = time.Duration(getEnvInt("MONGO_TIMEOUT_SECONDS", 10)) * time.Second - // Application envStr := getEnv("ENV", "DEVELOPMENT") if envStr == "PRODUCTION" { cfg.Env = Production @@ -83,19 +87,16 @@ func Load() (*Config, error) { 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 + cfg.ScheduleCheckAppTime = getEnv("SCHEDULE_CHECK_APP_TIME", "0 7 * * *") - // 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() @@ -111,6 +112,34 @@ func Load() (*Config, error) { return cfg, nil } +// databaseFromURI extracts the database name from a Mongo connection string, +// falling back to DefaultDatabaseName (Java behavior). +func databaseFromURI(uri string) string { + opts := options.Client().ApplyURI(uri) + if opts != nil && opts.Auth != nil && opts.Auth.AuthSource != "" { + // AuthSource is not the data DB; ignore it. + _ = opts + } + // Manual parse: scheme://...host[:port]/? + rest := uri + if idx := strings.Index(rest, "://"); idx >= 0 { + rest = rest[idx+3:] + } + slash := strings.Index(rest, "/") + if slash < 0 { + return DefaultDatabaseName + } + tail := rest[slash+1:] + if q := strings.Index(tail, "?"); q >= 0 { + tail = tail[:q] + } + tail = strings.TrimSpace(tail) + if tail == "" { + return DefaultDatabaseName + } + return tail +} + func getEnv(key, defaultValue string) string { if value := os.Getenv(key); value != "" { return value @@ -147,7 +176,3 @@ func (c *Config) IsAdmin(userID int64) bool { } return false } - -func (c *Config) GetScopeName() string { - return strings.ToLower(string(c.Env)) -} diff --git a/internal/model/abstract_model.go b/internal/model/abstract_model.go new file mode 100644 index 0000000..65d4bc9 --- /dev/null +++ b/internal/model/abstract_model.go @@ -0,0 +1,8 @@ +package model + +// AbstractModel mirrors Java AbstractModel: every persisted entity has _id (string) +// and a `class` discriminator equal to the simple type name. +type AbstractModel struct { + ID string `bson:"_id" json:"_id"` + Class string `bson:"class" json:"class"` +} diff --git a/internal/model/admin.go b/internal/model/admin.go index 1497ac4..1252a86 100644 --- a/internal/model/admin.go +++ b/internal/model/admin.go @@ -1,22 +1,23 @@ package model +// AdminID is the singleton document _id used by Java AdminRepository. +const AdminID = "admin" + type Admin struct { - Key string `bson:"_id,omitempty" json:"key"` - Groups []int64 `bson:"groups" json:"groups"` + AbstractModel `bson:",inline"` + Groups []int64 `bson:"groups" json:"groups"` } func NewAdmin() *Admin { return &Admin{ - Key: "admin", - Groups: make([]int64, 0), + AbstractModel: AbstractModel{ID: AdminID, Class: "Admin"}, + Groups: []int64{}, } } func (a *Admin) AddGroup(groupID int64) bool { - for _, g := range a.Groups { - if g == groupID { - return false // Already exists - } + if a.HasGroup(groupID) { + return false } a.Groups = append(a.Groups, groupID) return true diff --git a/internal/model/apple_app.go b/internal/model/apple_app.go index 549c3b3..46b6412 100644 --- a/internal/model/apple_app.go +++ b/internal/model/apple_app.go @@ -1,49 +1,59 @@ package model -import "time" +// AppleAppResponse mirrors Java AppleAppResponse record (api/apple/response). +type AppleAppResponse struct { + ID int64 `bson:"id" json:"id"` + AppID string `bson:"appId" json:"appId"` + Title string `bson:"title" json:"title"` + URL string `bson:"url" json:"url"` + Description string `bson:"description" json:"description"` + Icon string `bson:"icon" json:"icon"` + Genres []string `bson:"genres" json:"genres"` + GenreIDs []string `bson:"genreIds" json:"genreIds"` + PrimaryGenre string `bson:"primaryGenre" json:"primaryGenre"` + PrimaryGenreID int `bson:"primaryGenreId" json:"primaryGenreId"` + ContentRating string `bson:"contentRating" json:"contentRating"` + Languages []string `bson:"languages" json:"languages"` + Size string `bson:"size" json:"size"` + RequiredOsVersion string `bson:"requiredOsVersion" json:"requiredOsVersion"` + Released string `bson:"released" json:"released"` + Updated string `bson:"updated" json:"updated"` // ISO 8601 + ReleaseNotes string `bson:"releaseNotes" json:"releaseNotes"` + Version string `bson:"version" json:"version"` + Price float64 `bson:"price" json:"price"` + Currency string `bson:"currency" json:"currency"` + Free bool `bson:"free" json:"free"` + DeveloperID int64 `bson:"developerId" json:"developerId"` + Developer string `bson:"developer" json:"developer"` + DeveloperURL string `bson:"developerUrl" json:"developerUrl"` + DeveloperWebsite string `bson:"developerWebsite" json:"developerWebsite"` + Score float64 `bson:"score" json:"score"` + Reviews int `bson:"reviews" json:"reviews"` + CurrentVersionScore float64 `bson:"currentVersionScore" json:"currentVersionScore"` + CurrentVersionReviews int `bson:"currentVersionReviews" json:"currentVersionReviews"` + Screenshots []string `bson:"screenshots" json:"screenshots"` + IpadScreenshots []string `bson:"ipadScreenshots" json:"ipadScreenshots"` + AppletvScreenshots []string `bson:"appletvScreenshots" json:"appletvScreenshots"` + SupportedDevices []string `bson:"supportedDevices" json:"supportedDevices"` + Ratings int64 `bson:"ratings" json:"ratings"` + Histogram map[string]int64 `bson:"histogram" json:"histogram"` +} type AppleApp struct { - Key string `bson:"_id" json:"key"` - App AppleAppResponse `bson:"app" json:"app"` - UpdatedAt time.Time `bson:"updatedAt" json:"updatedAt"` + AbstractModel `bson:",inline"` + App AppleAppResponse `bson:"app" json:"app"` + Millis int64 `bson:"millis" json:"millis"` // cache timestamp (ms since epoch) } -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 { +func NewAppleApp(appID string, response AppleAppResponse, millis int64) *AppleApp { return &AppleApp{ - Key: appID, - App: response, - UpdatedAt: time.Now(), + AbstractModel: AbstractModel{ID: appID, Class: "AppleApp"}, + App: response, + Millis: millis, } } -func (a *AppleApp) IsExpired(cacheSeconds int) bool { - return time.Since(a.UpdatedAt).Seconds() > float64(cacheSeconds) +// IsExpired reports whether the cache entry is older than cacheMillis. +func (a *AppleApp) IsExpired(nowMillis, cacheMillis int64) bool { + return nowMillis-a.Millis > cacheMillis } diff --git a/internal/model/google_app.go b/internal/model/google_app.go index c166c99..dc02ec5 100644 --- a/internal/model/google_app.go +++ b/internal/model/google_app.go @@ -1,48 +1,91 @@ package model -import "time" +// Category mirrors Java GoogleAppResponse.Category nested record. +type Category struct { + Name string `bson:"name" json:"name"` + ID string `bson:"id" json:"id"` +} + +// Feature mirrors Java GoogleAppResponse.Feature nested record. +type Feature struct { + Title string `bson:"title" json:"title"` + Description string `bson:"description" json:"description"` +} + +// GoogleAppResponse mirrors Java GoogleAppResponse record (api/google/response). +type GoogleAppResponse struct { + Title string `bson:"title" json:"title"` + Description string `bson:"description" json:"description"` + DescriptionHTML string `bson:"descriptionHTML" json:"descriptionHTML"` + Summary string `bson:"summary" json:"summary"` + Installs string `bson:"installs" json:"installs"` + MinInstalls int64 `bson:"minInstalls" json:"minInstalls"` + MaxInstalls int64 `bson:"maxInstalls" json:"maxInstalls"` + Score float64 `bson:"score" json:"score"` + ScoreText string `bson:"scoreText" json:"scoreText"` + Ratings int64 `bson:"ratings" json:"ratings"` + Reviews int64 `bson:"reviews" json:"reviews"` + Histogram map[string]int64 `bson:"histogram" json:"histogram"` + Price float64 `bson:"price" json:"price"` + Free bool `bson:"free" json:"free"` + Currency string `bson:"currency" json:"currency"` + PriceText string `bson:"priceText" json:"priceText"` + OffersIAP bool `bson:"offersIAP" json:"offersIAP"` + IAPRange string `bson:"IAPRange" json:"IAPRange"` + AndroidVersion string `bson:"androidVersion" json:"androidVersion"` + AndroidVersionText string `bson:"androidVersionText" json:"androidVersionText"` + AndroidMaxVersion string `bson:"androidMaxVersion" json:"androidMaxVersion"` + Developer string `bson:"developer" json:"developer"` + DeveloperID string `bson:"developerId" json:"developerId"` + DeveloperEmail string `bson:"developerEmail" json:"developerEmail"` + DeveloperWebsite string `bson:"developerWebsite" json:"developerWebsite"` + DeveloperAddress string `bson:"developerAddress" json:"developerAddress"` + DeveloperLegalName string `bson:"developerLegalName" json:"developerLegalName"` + DeveloperLegalEmail string `bson:"developerLegalEmail" json:"developerLegalEmail"` + DeveloperLegalAddress string `bson:"developerLegalAddress" json:"developerLegalAddress"` + DeveloperLegalPhoneNumber string `bson:"developerLegalPhoneNumber" json:"developerLegalPhoneNumber"` + PrivacyPolicy string `bson:"privacyPolicy" json:"privacyPolicy"` + DeveloperInternalID string `bson:"developerInternalID" json:"developerInternalID"` + Genre string `bson:"genre" json:"genre"` + GenreID string `bson:"genreId" json:"genreId"` + Categories []Category `bson:"categories" json:"categories"` + Icon string `bson:"icon" json:"icon"` + HeaderImage string `bson:"headerImage" json:"headerImage"` + Screenshots []string `bson:"screenshots" json:"screenshots"` + Video string `bson:"video" json:"video"` + VideoImage string `bson:"videoImage" json:"videoImage"` + PreviewVideo string `bson:"previewVideo" json:"previewVideo"` + ContentRating string `bson:"contentRating" json:"contentRating"` + ContentRatingDescription string `bson:"contentRatingDescription" json:"contentRatingDescription"` + AdSupported bool `bson:"adSupported" json:"adSupported"` + Released string `bson:"released" json:"released"` + Updated int64 `bson:"updated" json:"updated"` // ms since epoch + Version string `bson:"version" json:"version"` + RecentChanges string `bson:"recentChanges" json:"recentChanges"` + Comments []string `bson:"comments" json:"comments"` + Preregister bool `bson:"preregister" json:"preregister"` + EarlyAccessEnabled bool `bson:"earlyAccessEnabled" json:"earlyAccessEnabled"` + IsAvailableInPlayPass bool `bson:"isAvailableInPlayPass" json:"isAvailableInPlayPass"` + EditorsChoice bool `bson:"editorsChoice" json:"editorsChoice"` + Features []Feature `bson:"features" json:"features"` + AppID string `bson:"appId" json:"appId"` + URL string `bson:"url" json:"url"` +} type GoogleApp struct { - Key string `bson:"_id" json:"key"` - App GoogleAppResponse `bson:"app" json:"app"` - UpdatedAt time.Time `bson:"updatedAt" json:"updatedAt"` + AbstractModel `bson:",inline"` + App GoogleAppResponse `bson:"app" json:"app"` + Millis int64 `bson:"millis" json:"millis"` } -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 { +func NewGoogleApp(appID string, response GoogleAppResponse, millis int64) *GoogleApp { return &GoogleApp{ - Key: appID, - App: response, - UpdatedAt: time.Now(), + AbstractModel: AbstractModel{ID: appID, Class: "GoogleApp"}, + App: response, + Millis: millis, } } -func (g *GoogleApp) IsExpired(cacheSeconds int) bool { - return time.Since(g.UpdatedAt).Seconds() > float64(cacheSeconds) +func (g *GoogleApp) IsExpired(nowMillis, cacheMillis int64) bool { + return nowMillis-g.Millis > cacheMillis } diff --git a/internal/model/group.go b/internal/model/group.go index 8f25926..5073d93 100644 --- a/internal/model/group.go +++ b/internal/model/group.go @@ -1,28 +1,48 @@ package model +import "strconv" + +// AppInfo mirrors Java AppleAppInfo / GoogleAppInfo records. 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"` + AbstractModel `bson:",inline"` + AppleApps []AppInfo `bson:"appleApps" json:"appleApps"` + GoogleApps []AppInfo `bson:"googleApps" json:"googleApps"` +} + +// GroupIDToKey converts a Telegram chat ID to the string _id used by Java. +func GroupIDToKey(groupID int64) string { + return strconv.FormatInt(groupID, 10) +} + +// GroupKeyToID parses a stored _id back to int64. +func GroupKeyToID(key string) (int64, error) { + return strconv.ParseInt(key, 10, 64) } func NewGroup(groupID int64) *Group { return &Group{ - Key: groupID, - AppleApps: make([]AppInfo, 0), - GoogleApps: make([]AppInfo, 0), + AbstractModel: AbstractModel{ID: GroupIDToKey(groupID), Class: "Group"}, + AppleApps: []AppInfo{}, + GoogleApps: []AppInfo{}, } } +// GroupID returns the int64 chat ID parsed from the stored string _id. +// Returns 0 if parsing fails (matches Java behaviour where _id always parses). +func (g *Group) GroupID() int64 { + id, _ := GroupKeyToID(g.ID) + return id +} + func (g *Group) AddAppleApp(appID, country string) bool { for _, app := range g.AppleApps { - if app.AppID == appID && app.Country == country { - return false // Already exists + if app.AppID == appID { + return false } } g.AppleApps = append(g.AppleApps, AppInfo{AppID: appID, Country: country}) @@ -41,8 +61,8 @@ func (g *Group) RemoveAppleApp(appID string) bool { func (g *Group) AddGoogleApp(appID, country string) bool { for _, app := range g.GoogleApps { - if app.AppID == appID && app.Country == country { - return false // Already exists + if app.AppID == appID { + return false } } g.GoogleApps = append(g.GoogleApps, AppInfo{AppID: appID, Country: country}) diff --git a/internal/model/non_updated_app.go b/internal/model/non_updated_app.go index 0573a4e..12e4ca8 100644 --- a/internal/model/non_updated_app.go +++ b/internal/model/non_updated_app.go @@ -1,12 +1,14 @@ package model +// NonUpdatedApp is a transient (not persisted) struct used by the daily check +// job to report apps not updated in N days. type NonUpdatedApp struct { AppID string Title string Days int Updated string Score float64 - Reviews interface{} // Can be int or string + Reviews int64 Ratings int64 IsApple bool } diff --git a/internal/repository/admin_repository.go b/internal/repository/admin_repository.go index 5afb5bd..cde918f 100644 --- a/internal/repository/admin_repository.go +++ b/internal/repository/admin_repository.go @@ -11,22 +11,36 @@ import ( "go.mongodb.org/mongo-driver/mongo/options" ) +// AdminRepository persists the singleton Admin document. +// Java equivalent stores it in the "common" collection at _id="admin". type AdminRepository struct { collection *mongo.Collection } func NewAdminRepository() *AdminRepository { - return &AdminRepository{ - collection: GetCollection("admin"), + return &AdminRepository{collection: GetCollection("common")} +} + +// Init creates the singleton document if it does not yet exist. +func (r *AdminRepository) Init() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + count, err := r.collection.CountDocuments(ctx, bson.M{"_id": model.AdminID}) + if err != nil { + return fmt.Errorf("failed to count admin: %w", err) } + if count > 0 { + return nil + } + return r.Save(ctx, model.NewAdmin()) } func (r *AdminRepository) Get(ctx context.Context) (*model.Admin, error) { admin := &model.Admin{} - err := r.collection.FindOne(ctx, bson.M{"_id": "admin"}).Decode(admin) + err := r.collection.FindOne(ctx, bson.M{"_id": model.AdminID}).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) @@ -36,43 +50,39 @@ func (r *AdminRepository) Get(ctx context.Context) (*model.Admin, error) { 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) + _, err := r.collection.ReplaceOne(ctx, bson.M{"_id": model.AdminID}, admin, opts) if err != nil { return fmt.Errorf("failed to save admin: %w", err) } return nil } -func (r *AdminRepository) AddGroup(groupID int64) error { +func (r *AdminRepository) AddGroup(groupID int64) (added bool, err error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() admin, err := r.Get(ctx) if err != nil { - return err + return false, err } - if !admin.AddGroup(groupID) { - return fmt.Errorf("group already exists") + return false, nil } - - return r.Save(ctx, admin) + return true, r.Save(ctx, admin) } -func (r *AdminRepository) RemoveGroup(groupID int64) error { +func (r *AdminRepository) RemoveGroup(groupID int64) (removed bool, err error) { ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() admin, err := r.Get(ctx) if err != nil { - return err + return false, err } - if !admin.RemoveGroup(groupID) { - return fmt.Errorf("group not found") + return false, nil } - - return r.Save(ctx, admin) + return true, r.Save(ctx, admin) } func (r *AdminRepository) HasGroup(groupID int64) (bool, error) { @@ -83,7 +93,6 @@ func (r *AdminRepository) HasGroup(groupID int64) (bool, error) { if err != nil { return false, err } - return admin.HasGroup(groupID), nil } @@ -95,6 +104,5 @@ func (r *AdminRepository) GetAllGroups() ([]int64, error) { 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 index f0ce0c8..2c0eb82 100644 --- a/internal/repository/apple_app_repository.go +++ b/internal/repository/apple_app_repository.go @@ -12,14 +12,14 @@ import ( "go.mongodb.org/mongo-driver/mongo/options" ) +// AppleAppRepository caches Apple app responses in the "apple_app" collection. +// Java schema stores _id=appId, app=AppleAppResponse, millis=cache timestamp. type AppleAppRepository struct { collection *mongo.Collection } func NewAppleAppRepository() *AppleAppRepository { - return &AppleAppRepository{ - collection: GetCollection("apple_app"), - } + return &AppleAppRepository{collection: GetCollection("apple_app")} } func (r *AppleAppRepository) Get(ctx context.Context, appID string) (*model.AppleApp, error) { @@ -27,7 +27,7 @@ func (r *AppleAppRepository) Get(ctx context.Context, appID string) (*model.Appl 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, nil } return nil, fmt.Errorf("failed to get apple app: %w", err) } @@ -35,27 +35,27 @@ func (r *AppleAppRepository) Get(ctx context.Context, appID string) (*model.Appl } 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) + _, err := r.collection.ReplaceOne(ctx, bson.M{"_id": app.ID}, app, opts) if err != nil { return fmt.Errorf("failed to save apple app: %w", err) } return nil } +// GetCached returns a cached entry if it exists and has not expired (per +// AppCacheSeconds). Returns (nil, nil) on cache miss. 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 { + if err != nil || app == nil { return nil, err } - - if app != nil && !app.IsExpired(config.GlobalConfig.AppCacheSeconds) { - return app, nil + cacheMillis := int64(config.GlobalConfig.AppCacheSeconds) * 1000 + if app.IsExpired(time.Now().UnixMilli(), cacheMillis) { + return nil, nil } - - return nil, nil // Cache expired or not found + return app, nil } diff --git a/internal/repository/google_app_repository.go b/internal/repository/google_app_repository.go index 1c44c00..fcfc5cf 100644 --- a/internal/repository/google_app_repository.go +++ b/internal/repository/google_app_repository.go @@ -12,14 +12,13 @@ import ( "go.mongodb.org/mongo-driver/mongo/options" ) +// GoogleAppRepository caches Google Play responses in the "google_app" collection. type GoogleAppRepository struct { collection *mongo.Collection } func NewGoogleAppRepository() *GoogleAppRepository { - return &GoogleAppRepository{ - collection: GetCollection("google_app"), - } + return &GoogleAppRepository{collection: GetCollection("google_app")} } func (r *GoogleAppRepository) Get(ctx context.Context, appID string) (*model.GoogleApp, error) { @@ -27,7 +26,7 @@ func (r *GoogleAppRepository) Get(ctx context.Context, appID string) (*model.Goo 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, nil } return nil, fmt.Errorf("failed to get google app: %w", err) } @@ -35,9 +34,8 @@ func (r *GoogleAppRepository) Get(ctx context.Context, appID string) (*model.Goo } 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) + _, err := r.collection.ReplaceOne(ctx, bson.M{"_id": app.ID}, app, opts) if err != nil { return fmt.Errorf("failed to save google app: %w", err) } @@ -49,13 +47,12 @@ func (r *GoogleAppRepository) GetCached(appID string) (*model.GoogleApp, error) defer cancel() app, err := r.Get(ctx, appID) - if err != nil { + if err != nil || app == nil { return nil, err } - - if app != nil && !app.IsExpired(config.GlobalConfig.AppCacheSeconds) { - return app, nil + cacheMillis := int64(config.GlobalConfig.AppCacheSeconds) * 1000 + if app.IsExpired(time.Now().UnixMilli(), cacheMillis) { + return nil, nil } - - return nil, nil // Cache expired or not found + return app, nil } diff --git a/internal/repository/group_repository.go b/internal/repository/group_repository.go index 47277c7..317397f 100644 --- a/internal/repository/group_repository.go +++ b/internal/repository/group_repository.go @@ -11,22 +11,41 @@ import ( "go.mongodb.org/mongo-driver/mongo/options" ) +// GroupRepository persists Group documents in the "group" collection. +// Java schema stores _id as the string form of the Telegram chat ID. type GroupRepository struct { collection *mongo.Collection } func NewGroupRepository() *GroupRepository { - return &GroupRepository{ - collection: GetCollection("group"), + return &GroupRepository{collection: GetCollection("group")} +} + +// Init creates an empty Group if not present. +func (r *GroupRepository) Init(ctx context.Context, groupID int64) error { + exists, err := r.Exists(ctx, groupID) + if err != nil { + return err } + if exists { + return nil + } + return r.Save(ctx, model.NewGroup(groupID)) +} + +func (r *GroupRepository) Exists(ctx context.Context, groupID int64) (bool, error) { + count, err := r.collection.CountDocuments(ctx, bson.M{"_id": model.GroupIDToKey(groupID)}) + if err != nil { + return false, fmt.Errorf("failed to count group: %w", err) + } + return count > 0, nil } 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) + err := r.collection.FindOne(ctx, bson.M{"_id": model.GroupIDToKey(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) @@ -36,7 +55,7 @@ func (r *GroupRepository) Get(ctx context.Context, groupID int64) (*model.Group, 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) + _, err := r.collection.ReplaceOne(ctx, bson.M{"_id": group.ID}, group, opts) if err != nil { return fmt.Errorf("failed to save group: %w", err) } @@ -44,73 +63,69 @@ func (r *GroupRepository) Save(ctx context.Context, group *model.Group) error { } func (r *GroupRepository) Delete(ctx context.Context, groupID int64) error { - _, err := r.collection.DeleteOne(ctx, bson.M{"_id": groupID}) + _, err := r.collection.DeleteOne(ctx, bson.M{"_id": model.GroupIDToKey(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) +func (r *GroupRepository) shortCtx() (context.Context, context.CancelFunc) { + return context.WithTimeout(context.Background(), 5*time.Second) +} + +func (r *GroupRepository) AddAppleApp(groupID int64, appID, country string) (added bool, err error) { + ctx, cancel := r.shortCtx() defer cancel() group, err := r.Get(ctx, groupID) if err != nil { - return err + return false, err } - if !group.AddAppleApp(appID, country) { - return fmt.Errorf("apple app already exists in group") + return false, nil } - - return r.Save(ctx, group) + return true, r.Save(ctx, group) } -func (r *GroupRepository) RemoveAppleApp(groupID int64, appID string) error { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) +func (r *GroupRepository) RemoveAppleApp(groupID int64, appID string) (removed bool, err error) { + ctx, cancel := r.shortCtx() defer cancel() group, err := r.Get(ctx, groupID) if err != nil { - return err + return false, err } - if !group.RemoveAppleApp(appID) { - return fmt.Errorf("apple app not found in group") + return false, nil } - - return r.Save(ctx, group) + return true, r.Save(ctx, group) } -func (r *GroupRepository) AddGoogleApp(groupID int64, appID, country string) error { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) +func (r *GroupRepository) AddGoogleApp(groupID int64, appID, country string) (added bool, err error) { + ctx, cancel := r.shortCtx() defer cancel() group, err := r.Get(ctx, groupID) if err != nil { - return err + return false, err } - if !group.AddGoogleApp(appID, country) { - return fmt.Errorf("google app already exists in group") + return false, nil } - - return r.Save(ctx, group) + return true, r.Save(ctx, group) } -func (r *GroupRepository) RemoveGoogleApp(groupID int64, appID string) error { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) +func (r *GroupRepository) RemoveGoogleApp(groupID int64, appID string) (removed bool, err error) { + ctx, cancel := r.shortCtx() defer cancel() group, err := r.Get(ctx, groupID) if err != nil { - return err + return false, err } - if !group.RemoveGoogleApp(appID) { - return fmt.Errorf("google app not found in group") + return false, nil } - - return r.Save(ctx, group) + return true, r.Save(ctx, group) } diff --git a/internal/scheduler/scheduler.go b/internal/scheduler/scheduler.go index 202c769..031d8b2 100644 --- a/internal/scheduler/scheduler.go +++ b/internal/scheduler/scheduler.go @@ -35,9 +35,7 @@ func NewScheduler( 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, @@ -51,16 +49,12 @@ func NewScheduler( } 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) + if _, err := s.cron.AddFunc(s.cfg.ScheduleCheckAppTime, s.runDailyCheck); err != nil { + return fmt.Errorf("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 } @@ -71,27 +65,22 @@ func (s *Scheduler) Stop() { } 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 + silent := now.Weekday() == time.Saturday || now.Weekday() == time.Sunday + s.logger.Info("Running daily check job", zap.Bool("silent", silent)) groups, err := s.adminRepo.GetAllGroups() if err != nil { - s.logger.Error("Failed to get groups for daily check", zap.Error(err)) + s.logger.Error("Failed to get groups", zap.Error(err)) return } - - for _, groupID := range groups { - s.checkGroup(groupID, isWeekend) + for _, gid := range groups { + s.checkGroup(gid, silent, now) } - s.logger.Info("Daily check job completed", zap.Int("groupsChecked", len(groups))) } -func (s *Scheduler) checkGroup(groupID int64, isWeekend bool) { +func (s *Scheduler) checkGroup(groupID int64, silent bool, now time.Time) { ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) defer cancel() @@ -100,70 +89,52 @@ func (s *Scheduler) checkGroup(groupID int64, isWeekend bool) { 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) + threshold := s.cfg.NumDaysWarningNotUpdated + stale := make([]model.NonUpdatedApp, 0) - // 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)) + for _, info := range group.AppleApps { + app, err := s.appleScraper.GetApp(info.AppID, info.Country) + if err != nil || app == nil { + s.logger.Error("Apple fetch failed", zap.String("appId", info.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, + days := int(now.Sub(updatedTime).Hours() / 24) + if days > threshold { + stale = append(stale, model.NonUpdatedApp{ + AppID: info.AppID, Title: app.Title, - Days: daysSinceUpdate, - Updated: app.Updated[:10], + Days: days, + Updated: updatedTime.Format("2006-01-02"), Score: app.Score, - Reviews: app.Reviews, + Reviews: int64(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)) + for _, info := range group.GoogleApps { + app, err := s.googleScraper.GetApp(info.AppID, info.Country) + if err != nil || app == nil { + s.logger.Error("Google fetch failed", zap.String("appId", info.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, + days := int(now.Sub(updatedTime).Hours() / 24) + if days > threshold { + stale = append(stale, model.NonUpdatedApp{ + AppID: info.AppID, Title: app.Title, - Days: daysSinceUpdate, + Days: days, Updated: updatedTime.Format("2006-01-02"), Score: app.Score, Reviews: app.Reviews, @@ -173,60 +144,46 @@ func (s *Scheduler) checkGroup(groupID int64, isWeekend bool) { } } - // Send report - if len(nonUpdatedApps) == 0 { - s.logger.Info("No non-updated apps found for group", zap.Int64("groupId", groupID)) + if len(stale) == 0 { + s.logger.Info("All apps up-to-date", zap.Int64("groupId", groupID)) return } - - message := s.buildReport(groupID, nonUpdatedApps) - - var err2 error - if isWeekend { - err2 = s.bot.SendMessageSilent(groupID, message) + message := s.buildReport(groupID, stale, now) + var sendErr error + if silent { + sendErr = s.bot.SendMessageSilent(groupID, message) } else { - err2 = s.bot.SendMessage(groupID, message) + sendErr = 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)) + if sendErr != nil { + s.logger.Error("Send daily report failed", zap.Int64("groupId", groupID), zap.Error(sendErr)) } } -func (s *Scheduler) buildReport(groupID int64, nonUpdatedApps []model.NonUpdatedApp) string { - var rows [][]string - for _, app := range nonUpdatedApps { +func (s *Scheduler) buildReport(groupID int64, apps []model.NonUpdatedApp, now time.Time) string { + headers := []string{"App", "Store", "Days", "Updated", "Score", "Reviews", "Ratings"} + rows := make([][]string, 0, len(apps)) + for _, a := range apps { store := "Google" - if app.IsApple { + if a.IsApple { store = "Apple" } - rows = append(rows, []string{ - util.TruncateString(app.Title, 30), + util.TruncateString(a.Title, 30), store, - fmt.Sprintf("%d", app.Days), - app.Updated, - fmt.Sprintf("%.1f", app.Score), - fmt.Sprintf("%v", app.Reviews), - util.FormatNumber(app.Ratings), + fmt.Sprintf("%d", a.Days), + a.Updated, + fmt.Sprintf("%.1f", a.Score), + fmt.Sprintf("%d", a.Reviews), + util.FormatNumber(a.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", + 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) + len(apps), + util.BuildTable(headers, rows), + ) } diff --git a/internal/util/table.go b/internal/util/table.go index 6906509..ffb557c 100644 --- a/internal/util/table.go +++ b/internal/util/table.go @@ -5,66 +5,66 @@ import ( "strings" ) +// BuildTable mirrors Java bot/table/Table.java: +// - left-aligned columns padded to max(header, cell) width +// - "│" column separator +// - row separator inserted every 5 rows using "─" cells joined by "─┼─" +// +// Output is intended to be wrapped in
 for Telegram HTML rendering.
 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
+	widths := computeWidths(headers, rows)
 	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(" | ")
-		}
-	}
+	writeRow(&sb, headers, widths)
 	sb.WriteString("\n")
+	writeSeparator(&sb, widths)
 
-	// Separator
-	for i, width := range columnWidths {
-		sb.WriteString(strings.Repeat("-", width))
-		if i < len(columnWidths)-1 {
-			sb.WriteString("-+-")
+	for i, row := range rows {
+		sb.WriteString("\n")
+		if i > 0 && i%5 == 0 {
+			writeSeparator(&sb, widths)
+			sb.WriteString("\n")
 		}
+		writeRow(&sb, row, widths)
 	}
-	sb.WriteString("\n")
+	return sb.String()
+}
 
-	// Rows
+func computeWidths(headers []string, rows [][]string) []int {
+	widths := make([]int, len(headers))
+	for i, h := range headers {
+		widths[i] = len(h)
+	}
 	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(" | ")
-				}
+			if i < len(widths) && len(cell) > widths[i] {
+				widths[i] = len(cell)
 			}
 		}
-		sb.WriteString("\n")
 	}
+	return widths
+}
 
-	// Bottom border
-	sb.WriteString("```")
+func writeRow(sb *strings.Builder, cells []string, widths []int) {
+	for i, w := range widths {
+		var cell string
+		if i < len(cells) {
+			cell = cells[i]
+		}
+		sb.WriteString(padRight(cell, w))
+		if i < len(widths)-1 {
+			sb.WriteString(" │ ")
+		}
+	}
+}
 
-	return sb.String()
+func writeSeparator(sb *strings.Builder, widths []int) {
+	for i, w := range widths {
+		sb.WriteString(strings.Repeat("─", w))
+		if i < len(widths)-1 {
+			sb.WriteString("─┼─")
+		}
+	}
 }
 
 func padRight(s string, length int) string {
@@ -78,14 +78,18 @@ func TruncateString(s string, maxLen int) string {
 	if len(s) <= maxLen {
 		return s
 	}
+	if maxLen <= 3 {
+		return s[:maxLen]
+	}
 	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)
+	if n >= 1_000_000 {
+		return fmt.Sprintf("%.1fM", float64(n)/1_000_000)
+	}
+	if n >= 1_000 {
+		return fmt.Sprintf("%.1fK", float64(n)/1_000)
 	}
 	return fmt.Sprintf("%d", n)
 }